Smalleast23's picture
Update index.html
e31e483 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>场景美术3D资产分析工具</title>
<style>
/* =============================================
Scene Asset Analyzer - 场景美术3D资产分析工具
============================================= */
/* --- Reset & Base --- */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #FF6B35;
--primary-light: #FF8F5E;
--primary-dark: #E55A28;
--primary-bg: #FFF5F0;
--accent: #F7931E;
--success: #10B981;
--success-bg: #ECFDF5;
--warning: #F59E0B;
--warning-bg: #FFFBEB;
--danger: #EF4444;
--danger-bg: #FEF2F2;
--info: #3B82F6;
--info-bg: #EFF6FF;
--bg: #F8FAFB;
--bg-white: #FFFFFF;
--bg-card: #FFFFFF;
--bg-hover: #F1F5F9;
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-muted: #94A3B8;
--border: #E2E8F0;
--border-light: #F1F5F9;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow: 0 4px 6px -1px rgba(0,0,0,0.07), 0 2px 4px -1px rgba(0,0,0,0.04);
--shadow-md: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -2px rgba(0,0,0,0.03);
--shadow-lg: 0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04);
--font-sans: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif;
--transition: 0.2s ease;
}
html {
font-size: 14px;
}
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* --- Header --- */
.header {
background: var(--bg-white);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo h1 {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.badge {
font-size: 0.75rem;
color: var(--primary);
background: var(--primary-bg);
padding: 2px 10px;
border-radius: 20px;
font-weight: 500;
}
/* --- Buttons --- */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all var(--transition);
font-family: var(--font-sans);
white-space: nowrap;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
box-shadow: 0 2px 8px rgba(255,107,53,0.3);
}
.btn-primary:hover {
box-shadow: 0 4px 14px rgba(255,107,53,0.4);
transform: translateY(-1px);
}
.btn-outline {
background: var(--bg-white);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-outline:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-bg);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-lg {
padding: 12px 28px;
font-size: 1rem;
border-radius: var(--radius);
}
.btn-sm {
padding: 4px 10px;
font-size: 0.75rem;
}
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
}
.btn-danger:hover {
background: var(--danger-bg);
}
/* --- Main --- */
.main {
max-width: 1400px;
margin: 0 auto;
padding: 32px 24px;
}
/* --- Intro Section --- */
.intro-section {
margin-bottom: 24px;
}
.intro-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.intro-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
}
.intro-header:hover {
background: var(--bg-hover);
}
.intro-icon {
color: var(--primary);
display: flex;
align-items: center;
}
.intro-titles h2 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.intro-divider {
color: var(--text-muted);
font-weight: 300;
margin: 0 4px;
}
.intro-toggle {
margin-left: auto;
padding: 4px 8px;
transition: transform var(--transition);
}
.intro-toggle.collapsed svg {
transform: rotate(-90deg);
}
.intro-body {
padding: 20px 24px 24px;
transition: all 0.3s ease;
}
.intro-body.collapsed {
display: none;
}
.intro-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.intro-col h4 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
letter-spacing: 0.02em;
}
.intro-col p {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 10px;
}
.intro-col p strong {
color: var(--primary);
}
.intro-col ul {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.intro-col ul li {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
padding-left: 4px;
}
.intro-models {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-light);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.intro-models-label {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.intro-model-tag {
font-size: 0.75rem;
padding: 3px 12px;
border-radius: 20px;
font-weight: 500;
}
.intro-model-tag.sf {
background: #EDE9FE;
color: #7C3AED;
}
.intro-model-tag.gemini {
background: #DBEAFE;
color: #2563EB;
}
@media (max-width: 768px) {
.intro-columns {
grid-template-columns: 1fr;
gap: 20px;
}
}
/* --- Upload Section --- */
.upload-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.upload-area {
border: 2px dashed var(--border);
border-radius: var(--radius-xl);
padding: 60px 40px;
text-align: center;
background: var(--bg-white);
transition: all var(--transition);
cursor: pointer;
}
.upload-area:hover,
.upload-area.dragover {
border-color: var(--primary);
background: var(--primary-bg);
}
.upload-area.dragover {
transform: scale(1.01);
}
.upload-icon {
margin-bottom: 16px;
color: var(--text-muted);
}
.upload-area:hover .upload-icon,
.upload-area.dragover .upload-icon {
color: var(--primary);
}
.upload-area h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 4px;
}
.upload-area p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.upload-hint {
font-size: 0.8rem !important;
color: var(--text-muted) !important;
margin-top: 4px;
}
.upload-btn {
margin-top: 20px;
}
/* --- Preview Grid --- */
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.preview-item {
position: relative;
border-radius: var(--radius);
overflow: hidden;
border: 2px solid var(--border-light);
background: var(--bg-white);
transition: all var(--transition);
aspect-ratio: 16/10;
}
.preview-item:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-item .remove-btn {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0,0,0,0.6);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0;
transition: opacity var(--transition);
}
.preview-item:hover .remove-btn {
opacity: 1;
}
.preview-item .image-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 4px 8px;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
color: white;
font-size: 0.7rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
/* --- Action Bar --- */
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--bg-white);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.upload-count {
font-size: 0.9rem;
color: var(--text-secondary);
}
.upload-count span {
font-weight: 700;
color: var(--primary);
font-size: 1.25rem;
}
/* --- Progress Section --- */
.progress-section {
display: flex;
justify-content: center;
padding: 80px 0;
}
.progress-card {
text-align: center;
background: var(--bg-white);
padding: 48px 64px;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.progress-card h3 {
margin: 20px 0 8px;
font-size: 1.15rem;
}
.progress-text {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 24px;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-bar {
width: 300px;
height: 6px;
background: var(--border-light);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--primary), var(--accent));
border-radius: 3px;
transition: width 0.3s ease;
}
/* --- Result Section --- */
.result-section {
animation: fadeInUp 0.5s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.result-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.result-header h2 {
font-size: 1.5rem;
font-weight: 700;
}
.result-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 4px;
}
.result-subtitle span {
color: var(--primary);
font-weight: 600;
}
.result-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* --- Table --- */
.table-container {
background: var(--bg-white);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
overflow-x: auto;
box-shadow: var(--shadow-sm);
}
.asset-table {
width: 100%;
border-collapse: collapse;
min-width: 1100px;
}
.asset-table thead {
background: linear-gradient(135deg, #FFF5F0, #FFF0E8);
}
.asset-table th {
padding: 14px 16px;
text-align: left;
font-size: 0.8rem;
font-weight: 600;
color: var(--primary-dark);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--primary);
white-space: nowrap;
}
.asset-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
font-size: 0.875rem;
vertical-align: middle;
}
.asset-table tbody tr {
transition: background var(--transition);
}
.asset-table tbody tr:hover {
background: var(--bg-hover);
}
.asset-table tbody tr:last-child td {
border-bottom: none;
}
.col-no { width: 50px; text-align: center !important; }
.col-type { width: 100px; }
.col-name-cn { width: 140px; }
.col-name-en { width: 160px; }
.col-days { width: 130px; text-align: center !important; }
.col-thumb { width: 160px; }
.col-notes { min-width: 200px; }
.col-action { width: 100px; }
.td-center { text-align: center; }
/* Type Tag */
.type-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
}
.type-tag.building { background: #EFF6FF; color: #2563EB; }
.type-tag.mechanical { background: #FEF3C7; color: #D97706; }
.type-tag.pipe { background: #FEE2E2; color: #DC2626; }
.type-tag.prop { background: #F0FDF4; color: #16A34A; }
.type-tag.light { background: #FFF7ED; color: #EA580C; }
.type-tag.vegetation { background: #ECFDF5; color: #059669; }
.type-tag.ground { background: #F5F3FF; color: #7C3AED; }
.type-tag.vehicle { background: #F0F9FF; color: #0284C7; }
.type-tag.ui { background: #FDF4FF; color: #C026D3; }
.type-tag.fx { background: #FFFBEB; color: #D97706; }
.type-tag.texture { background: #FDF2F8; color: #DB2777; }
.type-tag.other { background: #F1F5F9; color: #475569; }
/* Asset Badges (reusable & tiling) */
.asset-badges {
display: flex;
gap: 4px;
margin-top: 4px;
flex-wrap: wrap;
}
.badge-reusable {
display: inline-block;
padding: 1px 6px;
background: #ECFDF5;
color: #059669;
border: 1px solid #A7F3D0;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 500;
cursor: help;
}
.badge-tiling {
display: inline-block;
padding: 1px 6px;
background: #FDF2F8;
color: #DB2777;
border: 1px solid #FBCFE8;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 500;
cursor: help;
}
/* Status tags removed - replaced by priority/difficulty tags */
/* Thumbnail - see updated styles below */
/* Days Cell */
.days-value {
font-weight: 700;
color: var(--primary);
font-size: 1rem;
}
/* Notes Cell */
.notes-cell {
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.5;
max-width: 300px;
}
/* Action Buttons */
.action-btns {
display: flex;
gap: 6px;
}
/* --- Summary Cards --- */
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-top: 24px;
}
.summary-card {
background: var(--bg-white);
border-radius: var(--radius);
padding: 20px;
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
}
.summary-card .icon-box {
width: 48px;
height: 48px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.summary-card .icon-box.orange { background: var(--primary-bg); }
.summary-card .icon-box.green { background: var(--success-bg); }
.summary-card .icon-box.blue { background: var(--info-bg); }
.summary-card .icon-box.yellow { background: var(--warning-bg); }
.summary-card .stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.summary-card .stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* --- Restart Bar --- */
.restart-bar {
margin-top: 24px;
text-align: center;
}
/* --- Modal --- */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: var(--bg-white);
border-radius: var(--radius-lg);
width: 90%;
max-width: 520px;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: slideUp 0.3s ease;
}
.modal-lg {
max-width: 640px;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
font-size: 1.1rem;
}
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: var(--bg-hover);
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
}
.modal-close:hover {
background: var(--danger-bg);
color: var(--danger);
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* --- Help Modal --- */
.help-item {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.help-step {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.9rem;
flex-shrink: 0;
}
.help-item h4 {
font-size: 0.95rem;
margin-bottom: 4px;
}
.help-item p {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* --- Edit Form --- */
.edit-form .form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.edit-form .form-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.edit-form label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
}
.edit-form input,
.edit-form select,
.edit-form textarea {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-family: var(--font-sans);
transition: border-color var(--transition);
background: var(--bg-white);
}
.edit-form input:focus,
.edit-form select:focus,
.edit-form textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(255,107,53,0.1);
}
.edit-form textarea {
resize: vertical;
}
/* --- Priority Tag --- */
.priority-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 600;
white-space: nowrap;
}
.priority-tag.p0 { background: #EF4444; color: white; }
.priority-tag.p1 { background: #F59E0B; color: white; }
.priority-tag.p2 { background: #3B82F6; color: white; }
.priority-tag.p3 { background: #94A3B8; color: white; }
/* --- Difficulty Tag --- */
.difficulty-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 500;
white-space: nowrap;
}
.difficulty-tag.easy { background: #ECFDF5; color: #059669; }
.difficulty-tag.medium { background: #EFF6FF; color: #2563EB; }
.difficulty-tag.hard { background: #FEF3C7; color: #D97706; }
.difficulty-tag.extreme { background: #FEE2E2; color: #DC2626; }
/* --- Thumb Cell updated --- */
.thumb-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.thumb-img {
width: 100px;
height: 64px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border);
cursor: pointer;
transition: all var(--transition);
}
.thumb-img:hover {
transform: scale(1.15);
z-index: 10;
position: relative;
box-shadow: var(--shadow-lg);
}
.thumb-cropped { border: 2px solid var(--primary); }
.location-desc {
font-size: 0.7rem;
color: var(--text-muted);
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- Action Bar extras --- */
.action-bar-left {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.scene-type-selector,
.model-select-inline {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
}
.scene-type-selector label,
.model-select-inline label {
color: var(--text-secondary);
white-space: nowrap;
font-weight: 500;
}
.scene-type-selector select,
.model-select-inline select {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-family: var(--font-sans);
background: var(--bg-white);
cursor: pointer;
}
.ai-status { font-size: 0.75rem; white-space: nowrap; }
.ai-status.available { color: var(--success); }
.ai-status.unavailable { color: var(--warning); }
/* --- AI Settings Modal --- */
.ai-settings-intro {
margin-bottom: 20px;
padding: 12px 16px;
background: var(--info-bg);
border-radius: var(--radius);
border-left: 4px solid var(--info);
}
.ai-settings-intro p { font-size: 0.85rem; color: var(--text-secondary); line-height: 1.6; }
.ai-config-section { display: flex; flex-direction: column; gap: 20px; }
.ai-config-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.ai-config-header { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.ai-config-icon {
width: 44px; height: 44px; border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 1rem; flex-shrink: 0;
}
.ai-config-icon.sf-icon { background: linear-gradient(135deg, #667EEA, #764BA2); color: white; }
.ai-config-icon.gemini-icon { background: linear-gradient(135deg, #4285F4, #34A853); color: white; }
.ai-config-header h4 { font-size: 0.95rem; font-weight: 600; }
.ai-config-desc { font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px; }
.api-key-input-wrapper { display: flex; gap: 8px; align-items: center; }
.api-key-input {
flex: 1; padding: 10px 14px; border: 1px solid var(--border);
border-radius: var(--radius-sm); font-size: 0.85rem; font-family: var(--font-sans);
transition: border-color var(--transition);
}
.api-key-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(255,107,53,0.1); }
.api-key-status { display: block; font-size: 0.8rem; margin-top: 6px; }
.api-key-status.configured { color: var(--success); }
.api-key-status.not-configured { color: var(--warning); }
.ai-config-card .form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 0; }
.ai-config-card label { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); }
/* --- Filter Bar --- */
.filter-bar {
display: flex; align-items: center; gap: 16px; padding: 14px 20px;
background: var(--bg-white); border: 1px solid var(--border);
border-radius: var(--radius); margin-bottom: 16px; flex-wrap: wrap;
}
.filter-group { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; }
.filter-group label { color: var(--text-secondary); white-space: nowrap; font-size: 0.8rem; }
.filter-group select {
padding: 5px 10px; border: 1px solid var(--border);
border-radius: var(--radius-sm); font-size: 0.8rem; font-family: var(--font-sans); background: var(--bg-white);
}
.search-input {
padding: 5px 12px; border: 1px solid var(--border);
border-radius: var(--radius-sm); font-size: 0.8rem; font-family: var(--font-sans); width: 160px;
}
.search-input:focus { outline: none; border-color: var(--primary); }
/* --- Image View --- */
.image-view-body {
display: flex; justify-content: center; align-items: center;
background: #000; padding: 10px; min-height: 400px;
}
.image-view-body img { max-width: 100%; max-height: 80vh; object-fit: contain; }
.modal-xl { max-width: 1100px; }
/* --- Toast --- */
.toast-msg {
position: fixed; bottom: 30px; left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--text-primary); color: white;
padding: 10px 24px; border-radius: 8px; font-size: 0.9rem;
z-index: 9999; opacity: 0; transition: all 0.3s ease; box-shadow: var(--shadow-lg);
}
.toast-msg.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* --- Pipeline & Advice --- */
.pipeline-section, .advice-section { margin-top: 24px; }
.section-title {
display: flex; align-items: center; gap: 8px;
font-size: 1.1rem; font-weight: 600; margin-bottom: 16px; color: var(--text-primary);
}
/* Pipeline Grid */
.pipeline-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.pipeline-card {
background: var(--bg-white);
border-radius: var(--radius);
padding: 20px;
border: 1px solid var(--border);
position: relative;
overflow: hidden;
}
.pipeline-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
}
.pipeline-card.phase-1::before { background: linear-gradient(90deg, #EF4444, #F59E0B); }
.pipeline-card.phase-2::before { background: linear-gradient(90deg, #3B82F6, #60A5FA); }
.pipeline-card.phase-3::before { background: linear-gradient(90deg, #94A3B8, #CBD5E1); }
.pipeline-card.phase-summary::before { background: linear-gradient(90deg, var(--primary), var(--accent)); }
.pipeline-phase-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.pipeline-card h4 {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.pipeline-stat {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 6px;
}
.pipeline-count {
font-weight: 700;
color: var(--primary);
font-size: 1.1rem;
}
.pipeline-days {
font-weight: 700;
color: var(--info);
font-size: 1.1rem;
}
.pipeline-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.pipeline-item-tag {
display: inline-block;
padding: 2px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.72rem;
color: var(--text-secondary);
}
.pipeline-item-more {
display: inline-block;
padding: 2px 8px;
font-size: 0.72rem;
color: var(--text-muted);
font-style: italic;
}
/* Advice Grid */
.advice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.advice-card {
background: var(--bg-white);
border-radius: var(--radius);
padding: 20px;
border: 1px solid var(--border);
display: flex;
gap: 14px;
transition: box-shadow var(--transition);
}
.advice-card:hover {
box-shadow: var(--shadow-md);
}
.advice-icon {
font-size: 1.5rem;
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border-radius: var(--radius-sm);
}
.advice-content h4 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 6px;
color: var(--text-primary);
}
.advice-content p {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.6;
}
/* --- Responsive --- */
@media (max-width: 768px) {
.header-inner {
padding: 0 16px;
}
.main {
padding: 16px;
}
.upload-area {
padding: 40px 20px;
}
.result-header {
flex-direction: column;
}
.result-actions {
width: 100%;
}
.result-actions .btn {
flex: 1;
justify-content: center;
}
.action-bar {
flex-direction: column;
gap: 12px;
text-align: center;
}
.edit-form .form-row {
grid-template-columns: 1fr;
}
.preview-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/xlsx/dist/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.4.0/exceljs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
</head>
<body>
<div id="app">
<!-- 顶部导航 -->
<header class="header">
<div class="header-inner">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="url(#logo-grad)"/>
<path d="M8 22L16 10L24 22H8Z" stroke="white" stroke-width="2" fill="none"/>
<circle cx="16" cy="18" r="2" fill="white"/>
<defs><linearGradient id="logo-grad" x1="0" y1="0" x2="32" y2="32"><stop stop-color="#FF6B35"/><stop offset="1" stop-color="#F7931E"/></linearGradient></defs>
</svg>
<h1>Scene Asset Analyzer</h1>
<span class="badge">Pro</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" id="aiSettingsBtn" title="AI 模型设置">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73v.01a7 7 0 1 1-2 0V5.73A2 2 0 0 1 12 2z"/><circle cx="12" cy="14" r="3"/></svg>
AI 设置
</button>
<button class="btn btn-ghost" id="themeToggle" title="切换深色/浅色模式">
<svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<button class="btn btn-ghost" id="helpBtn" title="使用帮助">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
</div>
</div>
</header>
<!-- 主内容区 -->
<main class="main">
<!-- 工具介绍 -->
<section class="intro-section" id="introSection">
<div class="intro-card">
<div class="intro-header">
<div class="intro-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</div>
<div class="intro-titles">
<h2>关于本工具 <span class="intro-divider">|</span> About This Tool</h2>
</div>
<button class="btn btn-ghost btn-sm intro-toggle" id="introToggle" title="收起/展开">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
</div>
<div class="intro-body" id="introBody">
<div class="intro-columns">
<div class="intro-col">
<h4>🇨🇳 中文</h4>
<p><strong>Scene Asset Analyzer</strong> 是一款基于 AI 视觉模型的游戏场景3D资产分析工具。上传游戏场景截图后,AI 将以资深场景美术的视角自动识别截图中的所有3D资产,并提供:</p>
<ul>
<li>📦 资产清单(含中英文命名)</li>
<li>📐 资产在原图中的精确定位标注</li>
<li>⏱️ 每项资产的预估制作人天</li>
<li>📝 专业的制作注意事项与建议</li>
<li>📥 一键导出 Excel(含截图)/ CSV</li>
</ul>
</div>
<div class="intro-col">
<h4>🌐 English</h4>
<p><strong>Scene Asset Analyzer</strong> is an AI-powered tool for analyzing 3D assets in game scene screenshots. Upload your scene screenshots and AI will automatically identify all 3D assets from a senior environment artist's perspective, providing:</p>
<ul>
<li>📦 Asset inventory with bilingual naming</li>
<li>📐 Precise location annotation on original images</li>
<li>⏱️ Estimated production time (man-days) per asset</li>
<li>📝 Professional production notes & suggestions</li>
<li>📥 One-click export to Excel (with images) / CSV</li>
</ul>
</div>
</div>
<div class="intro-models">
<span class="intro-models-label">支持模型 Supported Models:</span>
<span class="intro-model-tag sf">Qwen3-VL-32B</span>
<span class="intro-model-tag gemini">Gemini 3 Flash</span>
<span class="intro-model-tag gemini">Gemini 3 Pro</span>
</div>
</div>
</div>
</section>
<!-- 上传区域 -->
<section class="upload-section" id="uploadSection">
<div class="upload-area" id="uploadArea">
<div class="upload-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>
</div>
<h2>上传场景截图</h2>
<p>拖拽图片到此处,或点击上传</p>
<p class="upload-hint">支持 JPG、PNG、WebP 格式,可同时上传多张截图进行对比分析</p>
<input type="file" id="fileInput" accept="image/*" multiple hidden />
<button class="btn btn-primary upload-btn" id="selectBtn">选择图片</button>
</div>
<!-- 已上传图片预览 -->
<div class="preview-grid" id="previewGrid"></div>
<!-- 场景类型选择 & 分析按钮 -->
<div class="action-bar" id="actionBar" style="display:none;">
<div class="action-bar-left">
<div class="upload-count">
<span id="imageCount">0</span> 张截图已上传
</div>
<div class="scene-type-selector">
<label>场景类型:</label>
<select id="sceneTypeSelect">
<option value="auto">自动检测</option>
<option value="industrial">工业/科幻场景</option>
<option value="outdoor">户外自然场景</option>
<option value="interior">室内场景</option>
<option value="medieval">中世纪/古风场景</option>
<option value="urban">现代城市场景</option>
</select>
</div>
<div class="model-select-inline">
<label>AI 模型:</label>
<select id="modelSelectInline">
<option value="qwen3-vl-32b">Qwen3-VL-32B (SiliconFlow)</option>
<option value="gemini-3-flash">Gemini 3 Flash</option>
<option value="gemini-3-pro">Gemini 3 Pro</option>
</select>
<span class="ai-status" id="aiStatusInline"></span>
</div>
</div>
<button class="btn btn-primary btn-lg" id="analyzeBtn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
开始分析资产
</button>
</div>
</section>
<!-- 分析进度 -->
<section class="progress-section" id="progressSection" style="display:none;">
<div class="progress-card">
<div class="progress-icon">
<div class="spinner"></div>
</div>
<h3 id="progressTitle">正在分析场景资产...</h3>
<p class="progress-text" id="progressText">识别3D资产中</p>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
</section>
<!-- 结果表格区域 -->
<section class="result-section" id="resultSection" style="display:none;">
<!-- 结果头部 -->
<div class="result-header">
<div>
<h2>资产分析结果</h2>
<p class="result-subtitle">以资深场景美术视角分析,共识别 <span id="assetCount">0</span> 项3D资产</p>
</div>
<div class="result-actions">
<button class="btn btn-outline" id="addRowBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加资产
</button>
<button class="btn btn-outline" id="exportCsvBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
导出 CSV
</button>
<button class="btn btn-primary" id="exportExcelBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
导出 Excel
</button>
</div>
</div>
<!-- 筛选与排序工具栏 -->
<div class="filter-bar" id="filterBar">
<div class="filter-group">
<label>按类型筛选:</label>
<select id="filterType">
<option value="all">全部类型</option>
<option value="建筑结构">建筑结构</option>
<option value="机械设备">机械设备</option>
<option value="管道/线缆">管道/线缆</option>
<option value="道具/摆件">道具/摆件</option>
<option value="灯光/照明">灯光/照明</option>
<option value="植被/自然">植被/自然</option>
<option value="地面/地形">地面/地形</option>
<option value="交通工具">交通工具</option>
<option value="UI/标识">UI/标识</option>
<option value="特效/粒子">特效/粒子</option>
<option value="贴图/材质">贴图/材质</option>
</select>
</div>
<div class="filter-group">
<label>排序:</label>
<select id="sortBy">
<option value="default">默认顺序</option>
<option value="days-desc">人天(高→低)</option>
<option value="days-asc">人天(低→高)</option>
<option value="type">资产类型</option>
</select>
</div>
<div class="filter-group">
<input type="text" id="searchInput" class="search-input" placeholder="搜索资产名称..." />
</div>
</div>
<!-- 表格 -->
<div class="table-container">
<table class="asset-table" id="assetTable">
<thead>
<tr>
<th class="col-no">序号</th>
<th class="col-type">资产类型</th>
<th class="col-name-cn">中文命名</th>
<th class="col-name-en">英文命名</th>
<th class="col-days">预估人天(仅供参考)</th>
<th class="col-thumb">位置截图</th>
<th class="col-notes">制作注意事项(仅供参考)</th>
<th class="col-action">操作</th>
</tr>
</thead>
<tbody id="assetTableBody">
</tbody>
</table>
</div>
<!-- 统计摘要 -->
<div class="summary-cards" id="summaryCards"></div>
<!-- 管线估算 -->
<div class="pipeline-section" id="pipelineSection">
<h3 class="section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
制作管线估算
</h3>
<div class="pipeline-cards" id="pipelineCards"></div>
</div>
<!-- 制作建议 -->
<div class="advice-section" id="adviceSection">
<h3 class="section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
资深美术制作建议
</h3>
<div class="advice-cards" id="adviceCards"></div>
</div>
<!-- 重新分析 -->
<div class="restart-bar">
<button class="btn btn-ghost" id="restartBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
重新上传分析
</button>
</div>
</section>
</main>
<!-- AI 设置弹窗 -->
<div class="modal-overlay" id="aiSettingsModal" style="display:none;">
<div class="modal modal-lg">
<div class="modal-header">
<h3>🤖 AI 模型设置</h3>
<button class="modal-close" id="aiSettingsModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="ai-settings-intro">
<p>配置 AI 视觉模型的 API Key,启用后将使用 AI 自动分析场景截图中的3D资产。API Key 会保存在浏览器本地存储中,无需每次重新输入。</p>
</div>
<div class="ai-config-section">
<div class="ai-config-card">
<div class="ai-config-header">
<div class="ai-config-icon sf-icon">SF</div>
<div>
<h4>SiliconFlow - Qwen3-VL-32B-Instruct</h4>
<p class="ai-config-desc">通义千问视觉语言模型,擅长中文场景描述与分析</p>
</div>
</div>
<div class="form-group">
<label>SiliconFlow API Key</label>
<div class="api-key-input-wrapper">
<input type="password" id="siliconflowApiKey" placeholder="sk-..." class="api-key-input" />
<button class="btn btn-sm btn-ghost toggle-visibility" data-target="siliconflowApiKey" title="显示/隐藏">👁</button>
</div>
<span class="api-key-status" id="siliconflowStatus"></span>
</div>
</div>
<div class="ai-config-card">
<div class="ai-config-header">
<div class="ai-config-icon gemini-icon">G</div>
<div>
<h4>Google Gemini 3 Flash / Pro</h4>
<p class="ai-config-desc">Gemini 视觉模型,两个模型共用同一个 API Key</p>
</div>
</div>
<div class="form-group">
<label>Gemini API Key</label>
<div class="api-key-input-wrapper">
<input type="password" id="geminiApiKey" placeholder="AIza..." class="api-key-input" />
<button class="btn btn-sm btn-ghost toggle-visibility" data-target="geminiApiKey" title="显示/隐藏">👁</button>
</div>
<span class="api-key-status" id="geminiStatus"></span>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="aiSettingsClearBtn">清除所有 Key</button>
<button class="btn btn-primary" id="aiSettingsSaveBtn">保存设置</button>
</div>
</div>
</div>
<!-- 帮助弹窗 -->
<div class="modal-overlay" id="helpModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>使用帮助</h3>
<button class="modal-close" id="helpModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="help-item">
<div class="help-step">1</div>
<div>
<h4>配置 AI 模型</h4>
<p>点击顶部 "AI 设置" 按钮,输入对应模型的 API Key。支持 SiliconFlow (Qwen3-VL) 和 Google Gemini 3 模型。Key 保存在浏览器本地,至少需要配置一个模型的 API Key 才能进行分析。</p>
</div>
</div>
<div class="help-item">
<div class="help-step">2</div>
<div>
<h4>上传场景截图</h4>
<p>上传一张或多张游戏/项目场景截图,支持 JPG、PNG、WebP 格式。建议截图清晰,能看清场景中的各类3D资产。</p>
</div>
</div>
<div class="help-item">
<div class="help-step">3</div>
<div>
<h4>选择 AI 模型并分析</h4>
<p>在分析栏中选择要使用的 AI 模型,点击"开始分析资产",AI 将自动识别场景中的3D资产并给出专业分析。</p>
</div>
</div>
<div class="help-item">
<div class="help-step">4</div>
<div>
<h4>查看结果 & 导出</h4>
<p>表格展示所有识别资产。"位置截图"列会展示资产在截图中对应位置的裁切画面。支持导出 Excel / CSV。</p>
</div>
</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div class="modal-overlay" id="editModal" style="display:none;">
<div class="modal modal-lg">
<div class="modal-header">
<h3>编辑资产信息</h3>
<button class="modal-close" id="editModalClose">&times;</button>
</div>
<div class="modal-body">
<form id="editForm" class="edit-form">
<input type="hidden" id="editIndex" />
<div class="form-row">
<div class="form-group">
<label>资产类型</label>
<select id="editType">
<option value="建筑结构">建筑结构</option>
<option value="机械设备">机械设备</option>
<option value="管道/线缆">管道/线缆</option>
<option value="道具/摆件">道具/摆件</option>
<option value="灯光/照明">灯光/照明</option>
<option value="植被/自然">植被/自然</option>
<option value="地面/地形">地面/地形</option>
<option value="交通工具">交通工具</option>
<option value="UI/标识">UI/标识</option>
<option value="特效/粒子">特效/粒子</option>
<option value="贴图/材质">贴图/材质</option>
<option value="其他">其他</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>中文命名</label>
<input type="text" id="editNameCn" placeholder="例:红色管道(粗)" />
</div>
<div class="form-group">
<label>英文命名</label>
<input type="text" id="editNameEn" placeholder="例:Red_Pipe_Large" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>预估人天(仅供参考)</label>
<input type="number" id="editDays" step="0.5" min="0" placeholder="例:1.5" />
</div>
</div>
<div class="form-group">
<label>制作注意事项(仅供参考)</label>
<textarea id="editNotes" rows="4" placeholder="例:注意管道的弯曲接口处理,需要无缝连接..."></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="editCancelBtn">取消</button>
<button class="btn btn-primary" id="editSaveBtn">保存修改</button>
</div>
</div>
</div>
<!-- 图片查看弹窗 -->
<div class="modal-overlay" id="imageViewModal" style="display:none;">
<div class="modal modal-xl">
<div class="modal-header">
<h3 id="imageViewTitle">截图查看</h3>
<button class="modal-close" id="imageViewClose">&times;</button>
</div>
<div class="modal-body image-view-body">
<img id="imageViewImg" src="" alt="截图" />
</div>
</div>
</div>
</div>
<script>
/**
* AI 分析模块
* 支持: SiliconFlow Qwen3-VL-32B-Instruct, Gemini 3 Flash, Gemini 3 Pro
*/
var AIAnalyzer = (function() {
var STORAGE_KEY_SF = 'scene_analyzer_sf_api_key';
var STORAGE_KEY_GEMINI = 'scene_analyzer_gemini_api_key';
// 获取/保存 API Key
function getSiliconFlowKey() {
return localStorage.getItem(STORAGE_KEY_SF) || '';
}
function getGeminiKey() {
return localStorage.getItem(STORAGE_KEY_GEMINI) || '';
}
function saveSiliconFlowKey(key) {
if (key) localStorage.setItem(STORAGE_KEY_SF, key);
else localStorage.removeItem(STORAGE_KEY_SF);
}
function saveGeminiKey(key) {
if (key) localStorage.setItem(STORAGE_KEY_GEMINI, key);
else localStorage.removeItem(STORAGE_KEY_GEMINI);
}
function clearAllKeys() {
localStorage.removeItem(STORAGE_KEY_SF);
localStorage.removeItem(STORAGE_KEY_GEMINI);
}
// 检查模型是否可用
function isModelAvailable(model) {
if (model === 'qwen3-vl-32b') return !!getSiliconFlowKey();
if (model === 'gemini-3-flash' || model === 'gemini-3-pro') return !!getGeminiKey();
return false;
}
// 图片转 base64
function imageToBase64(file) {
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(reader.result); };
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 构建分析 prompt
function buildPrompt(sceneType, imageCount) {
var multiImageNote = '';
if (imageCount > 1) {
multiImageNote = '\n\n【重要 - 多角度截图处理规则】\n' +
'这 ' + imageCount + ' 张截图来自同一个场景的不同角度/位置。请遵循以下规则:\n' +
'1. 同一个物件/资产如果在多张截图中出现,只需记录一次(选择最清晰的那张截图作为 imageIndex)\n' +
'2. 判断依据:外观相同、类型相同、在场景中位置合理的物件视为同一资产\n' +
'3. 对于场景中明显重复使用的相同模型(如成排的路灯、重复的栏杆段、相同的椅子等),只作为一种资产记录一次,但在 reusable 字段标注复用信息\n';
}
return '你是一名拥有15年以上3A游戏项目经验的资深场景美术/环境艺术家(Senior Environment Artist),精通全流程3D资产制作,包括高模雕刻、低模拓扑、UV展开、贴图绘制(PBR全套)、LOD制作、引擎适配等。\n\n' +
'请以专业美术从业者的视角仔细分析场景截图,识别出场景中【所有可见的】3D资产/模型,不要遗漏任何道具。' +
multiImageNote + '\n\n' +
(sceneType && sceneType !== 'auto' ? '场景类型提示:' + sceneType + '\n\n' : '') +
'【识别要求 - 请务必全面】\n' +
'- 大型资产:建筑、车辆、大型设备等\n' +
'- 中型资产:家具、机器、箱子、垃圾桶、消防栓、自行车等\n' +
'- 小型道具:管道、线缆、开关面板、标识牌、灭火器、监控摄像头、门牌号、按钮等\n' +
'- 环境元素:地面材质(如有独特纹理)、墙面装饰、天花板结构、排水沟盖、井盖等\n' +
'- 灯光道具:路灯、壁灯、日光灯管、指示灯、霓虹灯、LED灯带等\n' +
'- 植被:树木、灌木、杂草、花盆、藤蔓、苔藓等\n' +
'- 特殊表面/贴图:如果场景中有明显的二方连续(tiling in one direction)或四方连续(tiling in two directions)贴图/纹理(如地砖、墙砖、铁丝网、格栅、穿孔板等),请也识别出来\n\n' +
'请以JSON数组格式返回分析结果,每个资产包含以下字段:\n' +
'- type: 资产类型(必须是以下之一:建筑结构、机械设备、管道/线缆、道具/摆件、灯光/照明、植被/自然、地面/地形、交通工具、UI/标识、特效/粒子、贴图/材质)\n' +
'- nameCn: 中文命名(例:红色管道(粗))\n' +
'- nameEn: 英文命名(例:Red_Pipe_Large,使用下划线连接)\n' +
'- days: 预估制作人天(见下方人天预估规则)\n' +
'- notes: 制作注意事项(详细的全流程制作建议)\n' +
'- locationDesc: 该资产在截图中的位置描述(如"画面左上方"、"画面中央偏右"等)\n' +
'- locationRect: 该资产在截图中的精确位置矩形,格式为 [x, y, width, height],值为0到1之间的比例值。\n' +
' 【极其重要】locationRect 的坐标系定义:\n' +
' - x: 资产左边缘距离图片左侧的比例(0=最左边,1=最右边)\n' +
' - y: 资产上边缘距离图片顶部的比例(0=最上面,1=最下面)\n' +
' - width: 资产宽度占图片总宽度的比例\n' +
' - height: 资产高度占图片总高度的比例\n' +
' 请确保矩形精确框住资产的实际像素位置,不要偏移。例如一个位于画面正中央的物体大约是 [0.4, 0.35, 0.2, 0.3]。\n' +
' 请仔细估算每个资产在画面中的像素位置再转换为比例值。\n' +
'- imageIndex: 该资产出现在第几张截图中(从0开始计数)\n' +
'- reusable: 是否可复用/是否在场景中多次出现(true/false)\n' +
'- reusableCount: 如果 reusable 为 true,估计场景中出现了多少个实例(整数)\n' +
'- reusableNote: 如果 reusable 为 true,简要说明复用情况(如"场景中有4根相同的柱子")\n' +
'- tilingType: 如果该资产是贴图/材质类型,标注是 "二方连续"、"四方连续" 或 "无"(其他类型留空字符串)\n\n' +
'【人天预估规则 - 按资深美术全流程标准】\n' +
'人天必须包含以下全流程工序的累计时间:\n' +
'1. 参考收集与概念确认\n' +
'2. 高模制作(ZBrush/Maya/Blender雕刻或硬表面建模)\n' +
'3. 低模拓扑(手动Retopo或自动减面后调整)\n' +
'4. UV展开与排布\n' +
'5. 贴图烘焙(法线、AO、曲率等)\n' +
'6. PBR材质绘制(BaseColor、Roughness、Metallic、Normal、Emissive等)\n' +
'7. LOD制作(至少2级LOD)\n' +
'8. 引擎导入、材质搭建与效果调试\n' +
'9. 最终审核与修改\n\n' +
'参考人天标准(资深美术一人全流程):\n' +
'- 汽车/摩托车等复杂交通工具:12-20人天\n' +
'- 大型建筑结构(完整建筑外观):15-30人天\n' +
'- 中型机械设备(发电机、工业设备):8-15人天\n' +
'- 大型道具(自动售货机、ATM机、商用冰柜等):5-10人天\n' +
'- 中型道具(椅子、桌子、垃圾桶、消防栓):3-5人天\n' +
'- 小型道具(开关、插座、灭火器、管道接头):1-3人天\n' +
'- 微型道具(螺丝、铭牌、小标识):0.5-1人天\n' +
'- 管道/线缆(每种类型):2-5人天\n' +
'- 灯具(含发光效果调试):2-4人天\n' +
'- 植被(树木含SpeedTree制作):5-10人天\n' +
'- 二方/四方连续贴图:2-5人天\n' +
'- 地面/墙面材质:2-4人天\n' +
'请根据资产的实际复杂度在以上范围内给出合理估值,宁多勿少。\n\n' +
'【notes 制作注意事项要求】\n' +
'每个资产的 notes 必须包含:建模方式推荐、面数预估、贴图分辨率建议、材质类型(PBR金属/粗糙度工作流)、UV注意事项、LOD策略、特殊工艺要点。\n\n' +
'请务必:\n' +
'1. 尽可能识别出场景中所有可见的3D资产,不要遗漏,越全面越好\n' +
'2. locationRect 必须精确对应资产在截图中的实际像素位置\n' +
'3. 只返回JSON数组,不要有其他文字说明\n' +
'4. 确保JSON格式正确无误';
}
// 调用 SiliconFlow Qwen3-VL API(视觉语言模型)
async function callQwenVL(images, sceneType, onProgress) {
var apiKey = getSiliconFlowKey();
if (!apiKey) throw new Error('未配置 SiliconFlow API Key');
onProgress && onProgress('正在准备图片数据...');
var imageContents = [];
for (var i = 0; i < images.length; i++) {
var b64 = await imageToBase64(images[i].file);
imageContents.push({
type: 'image_url',
image_url: { url: b64 }
});
}
var messages = [
{
role: 'user',
content: imageContents.concat([
{ type: 'text', text: buildPrompt(sceneType, images.length) }
])
}
];
onProgress && onProgress('正在调用 Qwen3.5-397B 模型分析...');
var response = await fetch('https://api.siliconflow.cn/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
},
body: JSON.stringify({
model: 'Qwen/Qwen3-VL-32B-Instruct',
messages: messages,
max_tokens: 16384,
temperature: 0.3
})
});
if (!response.ok) {
var errText = await response.text();
console.error('SiliconFlow API 错误响应:', errText);
throw new Error('SiliconFlow API 错误 (' + response.status + '): ' + errText.substring(0, 300));
}
var data;
try {
data = await response.json();
} catch(jsonErr) {
throw new Error('SiliconFlow API 返回了非 JSON 数据,请检查 API Key 是否正确');
}
console.log('SiliconFlow 完整返回数据:', JSON.stringify(data).substring(0, 1000));
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
console.error('SiliconFlow 返回数据异常:', JSON.stringify(data).substring(0, 500));
throw new Error('SiliconFlow 返回数据格式异常');
}
// Qwen3.5 可能返回 reasoning_content(思考内容)和 content(实际回复)
var msg = data.choices[0].message;
var content = msg.content;
// 如果 content 为空或 null,尝试从 reasoning_content 中获取
if (!content && msg.reasoning_content) {
console.log('content 为空,从 reasoning_content 中提取');
content = msg.reasoning_content;
}
if (!content) {
console.error('SiliconFlow 消息对象:', JSON.stringify(msg).substring(0, 500));
throw new Error('SiliconFlow 未返回有效文本内容,请查看 F12 控制台了解详情');
}
return parseAIResponse(content, images.length);
}
// 调用 Gemini API
async function callGemini(model, images, sceneType, onProgress) {
var apiKey = getGeminiKey();
if (!apiKey) throw new Error('未配置 Gemini API Key');
onProgress && onProgress('正在准备图片数据...');
var parts = [];
for (var i = 0; i < images.length; i++) {
var b64 = await imageToBase64(images[i].file);
// 去掉 data:image/xxx;base64, 前缀
var base64Data = b64.split(',')[1];
var mimeType = images[i].file.type || 'image/jpeg';
parts.push({
inline_data: {
mime_type: mimeType,
data: base64Data
}
});
}
parts.push({ text: buildPrompt(sceneType, images.length) });
var modelId = model === 'gemini-3-pro' ? 'gemini-3.1-pro-preview' : 'gemini-3-flash-preview';
onProgress && onProgress('正在调用 ' + (model === 'gemini-3-pro' ? 'Gemini 3 Pro' : 'Gemini 3 Flash') + ' 模型分析...');
var response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/' + modelId + ':generateContent?key=' + apiKey, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: parts }],
generationConfig: {
temperature: 0.3,
maxOutputTokens: 16384
}
})
});
if (!response.ok) {
var errText = await response.text();
console.error('Gemini API 错误响应:', errText);
throw new Error('Gemini API 错误 (' + response.status + '): ' + errText.substring(0, 300));
}
var data;
try {
data = await response.json();
} catch(jsonErr) {
throw new Error('Gemini API 返回了非 JSON 数据,请检查 API Key 和模型是否可用');
}
if (!data.candidates || !data.candidates[0] || !data.candidates[0].content) {
// 检查是否有 promptFeedback 阻止
if (data.promptFeedback && data.promptFeedback.blockReason) {
throw new Error('Gemini 内容被阻止: ' + data.promptFeedback.blockReason);
}
console.error('Gemini 返回数据异常:', JSON.stringify(data).substring(0, 500));
throw new Error('Gemini 返回数据格式异常');
}
// Gemini 2.5 Pro/Flash 是思考型模型,parts 数组可能包含多个部分:
// - thought: true 的是思考过程(不是我们要的)
// - thought: false/undefined 的才是实际回答内容
var parts = data.candidates[0].content.parts || [];
var content = '';
for (var p = 0; p < parts.length; p++) {
if (parts[p].text && !parts[p].thought) {
content += parts[p].text;
}
}
// 如果没找到非 thought 内容,回退到取所有 text
if (!content) {
for (var p2 = 0; p2 < parts.length; p2++) {
if (parts[p2].text) {
content += parts[p2].text;
}
}
}
if (!content) {
console.error('Gemini 返回 parts:', JSON.stringify(parts).substring(0, 500));
throw new Error('Gemini 未返回有效文本内容');
}
return parseAIResponse(content, images.length);
}
// 解析 AI 返回的 JSON
function parseAIResponse(rawText, imageCount) {
console.log('AI 原始返回内容(前1000字符):', rawText.substring(0, 1000));
// 尝试多种方式提取 JSON
var jsonStr = '';
// 方式1: 从 markdown code block 中提取
var jsonMatch = rawText.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
console.log('从 code block 中提取到 JSON');
}
// 方式2: 如果 code block 没匹配到,直接找数组
if (!jsonStr) {
jsonStr = rawText.trim();
}
// 清理可能的前缀/后缀文字,定位 JSON 数组
var startIdx = jsonStr.indexOf('[');
var endIdx = jsonStr.lastIndexOf(']');
if (startIdx >= 0 && endIdx > startIdx) {
jsonStr = jsonStr.substring(startIdx, endIdx + 1);
} else {
// 也许返回的是单个对象 {...}
var objStart = jsonStr.indexOf('{');
var objEnd = jsonStr.lastIndexOf('}');
if (objStart >= 0 && objEnd > objStart) {
jsonStr = '[' + jsonStr.substring(objStart, objEnd + 1) + ']';
console.log('AI 返回了单个对象,已包装为数组');
}
}
console.log('清理后的 JSON 字符串(前500字符):', jsonStr.substring(0, 500));
var parsed;
try {
parsed = JSON.parse(jsonStr);
} catch(e) {
console.error('JSON.parse 失败:', e.message);
console.error('AI 返回内容解析失败,原始文本(前500字符):', rawText.substring(0, 500));
console.error('提取的 JSON 字符串(前500字符):', jsonStr.substring(0, 500));
// 尝试修复常见的 JSON 问题
// 修复1:末尾被截断
var lastComplete = jsonStr.lastIndexOf('}');
if (lastComplete > 0) {
var fixedStr = jsonStr.substring(0, lastComplete + 1);
// 确保以 [ 开头
var fixStart = fixedStr.indexOf('[');
if (fixStart >= 0) {
fixedStr = fixedStr.substring(fixStart) + ']';
} else {
fixedStr = '[' + fixedStr + ']';
}
try {
parsed = JSON.parse(fixedStr);
console.log('JSON 截断修复成功,解析了 ' + parsed.length + ' 项');
} catch(e2) {
// 修复2:尝试移除尾部逗号
try {
var cleanedStr = fixedStr.replace(/,\s*([}\]])/g, '$1');
parsed = JSON.parse(cleanedStr);
console.log('JSON 尾部逗号修复成功,解析了 ' + parsed.length + ' 项');
} catch(e3) {
throw new Error('AI 返回内容无法解析为JSON,请重试。\n\nAI原始回复(前300字符):\n' + rawText.substring(0, 300));
}
}
} else {
throw new Error('AI 返回内容无法解析为JSON,请重试。\n\nAI原始回复(前300字符):\n' + rawText.substring(0, 300));
}
}
if (!Array.isArray(parsed)) {
// 如果是对象,尝试包装为数组
if (parsed && typeof parsed === 'object') {
parsed = [parsed];
} else {
throw new Error('AI 返回的不是数组格式');
}
}
// 校验并修正 locationRect 坐标
function validateRect(rect) {
if (!rect || !Array.isArray(rect) || rect.length < 4) return null;
var x = parseFloat(rect[0]) || 0;
var y = parseFloat(rect[1]) || 0;
var w = parseFloat(rect[2]) || 0;
var h = parseFloat(rect[3]) || 0;
// 检测坐标系并归一化到 0-1 范围
var maxVal = Math.max(x, y, x + w, y + h);
if (maxVal > 1) {
if (maxVal <= 100) {
// 百分比格式 (0-100)
x = x / 100; y = y / 100; w = w / 100; h = h / 100;
} else if (maxVal <= 1000) {
// Gemini 常见的 0-1000 坐标系
x = x / 1000; y = y / 1000; w = w / 1000; h = h / 1000;
} else {
// 像素值,尝试按常见分辨率归一化
var guessW = maxVal > 1500 ? 1920 : (maxVal > 1000 ? 1280 : 1000);
var guessH = maxVal > 1500 ? 1080 : (maxVal > 1000 ? 720 : 1000);
x = x / guessW; y = y / guessH; w = w / guessW; h = h / guessH;
}
}
// 有些 AI 返回的是 [x1, y1, x2, y2] 而非 [x, y, w, h]
// 判断依据:如果 w > 0.5 且 h > 0.5,且 x < w 且 y < h,很可能是 [x1,y1,x2,y2]
if (w > x && h > y && w > 0.3 && h > 0.3) {
// 可能是 [x1, y1, x2, y2] 格式
var possibleW = w - x;
var possibleH = h - y;
if (possibleW > 0.02 && possibleH > 0.02 && possibleW < 0.95 && possibleH < 0.95) {
w = possibleW;
h = possibleH;
}
}
// 钳位到合法范围
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
w = Math.max(0.02, Math.min(1 - x, w));
h = Math.max(0.02, Math.min(1 - y, h));
return [x, y, w, h];
}
// 规范化字段
return parsed.map(function(item) {
var imgIdx = parseInt(item.imageIndex) || 0;
if (imgIdx >= imageCount) imgIdx = 0;
var reusable = item.reusable === true || item.reusable === 'true';
var reusableCount = parseInt(item.reusableCount) || (reusable ? 2 : 0);
var tilingType = item.tilingType || '';
// 构建带复用和贴图标注的 notes
var notes = item.notes || '';
if (reusable && item.reusableNote) {
notes += '\n\n🔄 复用标注:' + item.reusableNote + '(场景中约 ' + reusableCount + ' 个实例)';
}
if (tilingType && tilingType !== '无' && tilingType !== '') {
notes += '\n\n🔲 贴图类型:' + tilingType + '贴图,需确保无缝平铺';
}
return {
type: item.type || '其他',
nameCn: item.nameCn || item.name_cn || '未命名',
nameEn: item.nameEn || item.name_en || 'Unnamed',
days: parseFloat(item.days) || 1,
notes: notes,
locationDesc: item.locationDesc || item.location_desc || '',
locationRect: validateRect(item.locationRect || item.location_rect),
thumbIndex: imgIdx,
reusable: reusable,
reusableCount: reusableCount,
tilingType: tilingType
};
});
}
// 根据位置信息裁切图片生成缩略图
function cropImageToCanvas(imageUrl, rect) {
return new Promise(function(resolve) {
if (!rect || !Array.isArray(rect) || rect.length < 4) {
resolve(null);
return;
}
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
var sx = Math.max(0, Math.min(1, rect[0])) * img.naturalWidth;
var sy = Math.max(0, Math.min(1, rect[1])) * img.naturalHeight;
var sw = Math.max(0.05, Math.min(1, rect[2])) * img.naturalWidth;
var sh = Math.max(0.05, Math.min(1, rect[3])) * img.naturalHeight;
// 确保不超出边界
if (sx + sw > img.naturalWidth) sw = img.naturalWidth - sx;
if (sy + sh > img.naturalHeight) sh = img.naturalHeight - sy;
// 输出尺寸(较大分辨率以确保 Excel 导出时图片清晰)
var outW = 480;
var outH = Math.round(outW * (sh / sw));
if (outH > 360) { outH = 360; outW = Math.round(outH * (sw / sh)); }
canvas.width = outW;
canvas.height = outH;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outW, outH);
// 画红色边框
ctx.strokeStyle = '#FF6B35';
ctx.lineWidth = 3;
ctx.strokeRect(1, 1, outW - 2, outH - 2);
resolve(canvas.toDataURL('image/jpeg', 0.85));
};
img.onerror = function() { resolve(null); };
img.src = imageUrl;
});
}
// 在原图上标注位置,生成高分辨率标注版大图
function annotateImageWithRect(imageUrl, rect) {
return new Promise(function(resolve) {
if (!rect || !Array.isArray(rect) || rect.length < 4) {
resolve(null);
return;
}
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
// 使用高分辨率:最大边1920px,保持清晰度
var maxSize = 1920;
var scale = Math.min(maxSize / img.naturalWidth, maxSize / img.naturalHeight, 1);
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 画暗色遮罩
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 还原标注区域
var rx = rect[0] * canvas.width;
var ry = rect[1] * canvas.height;
var rw = rect[2] * canvas.width;
var rh = rect[3] * canvas.height;
ctx.clearRect(rx, ry, rw, rh);
ctx.drawImage(img, rect[0]*img.naturalWidth, rect[1]*img.naturalHeight, rect[2]*img.naturalWidth, rect[3]*img.naturalHeight, rx, ry, rw, rh);
// 红色高亮边框
ctx.strokeStyle = '#FF6B35';
ctx.lineWidth = 4;
ctx.strokeRect(rx, ry, rw, rh);
// 添加资产名称标签背景
ctx.fillStyle = 'rgba(255,107,53,0.9)';
var labelH = 24;
var labelY = ry > labelH + 4 ? ry - labelH - 4 : ry + rh + 4;
ctx.fillRect(rx, labelY, Math.max(rw, 60), labelH);
resolve(canvas.toDataURL('image/jpeg', 0.92));
};
img.onerror = function() { resolve(null); };
img.src = imageUrl;
});
}
// 主分析入口
async function analyze(model, images, sceneType, onProgress) {
if (model === 'qwen3-vl-32b') {
return await callQwenVL(images, sceneType, onProgress);
} else if (model === 'gemini-3-flash' || model === 'gemini-3-pro') {
return await callGemini(model, images, sceneType, onProgress);
}
throw new Error('未知模型: ' + model);
}
return {
getSiliconFlowKey: getSiliconFlowKey,
getGeminiKey: getGeminiKey,
saveSiliconFlowKey: saveSiliconFlowKey,
saveGeminiKey: saveGeminiKey,
clearAllKeys: clearAllKeys,
isModelAvailable: isModelAvailable,
analyze: analyze,
cropImageToCanvas: cropImageToCanvas,
annotateImageWithRect: annotateImageWithRect
};
})();
</script>
<script>
/**
* 导出功能模块 - 支持 Excel (.xlsx) 和 CSV 格式
* Excel 导出使用 ExcelJS 实现图片嵌入
*/
// 将 base64 data URL 转成纯 base64 字符串
function dataUrlToBase64(dataUrl) {
if (!dataUrl) return null;
var idx = dataUrl.indexOf(',');
return idx >= 0 ? dataUrl.substring(idx + 1) : dataUrl;
}
// 将图片 URL(包括 ObjectURL)转换为 base64 data URL
function imageUrlToBase64(url) {
return new Promise(function(resolve) {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
var canvas = document.createElement('canvas');
// 保持较高分辨率以确保 Excel 中图片清晰
var maxW = 800, maxH = 600;
var scale = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
var ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', 0.85));
};
img.onerror = function() { resolve(null); };
img.src = url;
});
}
async function exportToExcel(assets, images, croppedThumbs) {
// 优先使用 ExcelJS(支持图片嵌入)
if (typeof ExcelJS !== 'undefined') {
try {
await exportToExcelWithExcelJS(assets, images, croppedThumbs);
return;
} catch(err) {
console.error('ExcelJS 导出失败,回退到 SheetJS:', err);
}
}
// 回退:使用 SheetJS(不支持图片)
if (typeof XLSX !== 'undefined') {
exportToExcelFallback(assets, images, croppedThumbs);
return;
}
alert('Excel 导出库尚未加载完成,请稍后再试或使用 CSV 导出');
}
// =================== ExcelJS 导出(支持图片嵌入)===================
async function exportToExcelWithExcelJS(assets, images, croppedThumbs) {
var workbook = new ExcelJS.Workbook();
workbook.creator = 'Scene Asset Analyzer';
workbook.created = new Date();
// ---- 主工作表:场景资产分析 ----
var ws = workbook.addWorksheet('场景资产分析', {
views: [{ state: 'frozen', ySplit: 1 }]
});
// 列定义(位置截图列加宽以容纳大图)
ws.columns = [
{ header: '序号', key: 'no', width: 8 },
{ header: '资产类型', key: 'type', width: 14 },
{ header: '中文命名', key: 'nameCn', width: 22 },
{ header: '英文命名', key: 'nameEn', width: 28 },
{ header: '预估人天(仅供参考)', key: 'days', width: 20 },
{ header: '位置截图', key: 'thumb', width: 48 },
{ header: '位置描述', key: 'locDesc', width: 20 },
{ header: '制作注意事项(仅供参考)', key: 'notes', width: 50 }
];
// 表头样式
var headerRow = ws.getRow(1);
headerRow.height = 28;
headerRow.eachCell(function(cell) {
cell.font = { bold: true, size: 11, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } };
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
cell.border = {
top: { style: 'thin', color: { argb: 'FFD0D0D0' } },
bottom: { style: 'thin', color: { argb: 'FFD0D0D0' } },
left: { style: 'thin', color: { argb: 'FFD0D0D0' } },
right: { style: 'thin', color: { argb: 'FFD0D0D0' } }
};
});
// 图片尺寸设置(标注版全图,宽屏比例)
var IMG_ROW_HEIGHT = 160; // 行高(point)
var IMG_WIDTH = 320; // 图片宽度(像素)
var IMG_HEIGHT = 140; // 图片高度(像素)
// 填充数据行
for (var i = 0; i < assets.length; i++) {
var asset = assets[i];
var rowIndex = i + 2; // Excel 行号从1开始,+1表头
var row = ws.addRow({
no: i + 1,
type: asset.type,
nameCn: asset.nameCn,
nameEn: asset.nameEn,
days: asset.days,
thumb: '', // 图片列先留空
locDesc: asset.locationDesc || '-',
notes: asset.notes
});
// 设置行高以容纳图片
row.height = IMG_ROW_HEIGHT;
// 单元格样式
row.eachCell(function(cell, colNumber) {
cell.alignment = { vertical: 'middle', wrapText: true };
if (colNumber === 1 || colNumber === 5) {
cell.alignment = { horizontal: 'center', vertical: 'middle' };
}
cell.border = {
top: { style: 'thin', color: { argb: 'FFE0E0E0' } },
bottom: { style: 'thin', color: { argb: 'FFE0E0E0' } },
left: { style: 'thin', color: { argb: 'FFE0E0E0' } },
right: { style: 'thin', color: { argb: 'FFE0E0E0' } }
};
// 斑马纹
if (i % 2 === 1) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF5F7FA' } };
}
});
// 嵌入图片(优先使用标注版全图,即原图上画了位置框的版本)
var thumbData = croppedThumbs ? croppedThumbs[i] : null;
if (!thumbData) {
// 没有标注图,尝试用原图转 base64
var thumbIdx = asset.thumbIndex || 0;
if (images && images[thumbIdx] && images[thumbIdx].url) {
thumbData = await imageUrlToBase64(images[thumbIdx].url);
}
}
if (thumbData) {
var b64 = dataUrlToBase64(thumbData);
if (b64) {
try {
var imageId = workbook.addImage({
base64: b64,
extension: 'jpeg'
});
ws.addImage(imageId, {
tl: { col: 5.05, row: rowIndex - 1 + 0.08 }, // 略微偏移以留出内边距
ext: { width: IMG_WIDTH, height: IMG_HEIGHT }
});
} catch(imgErr) {
console.warn('嵌入第 ' + (i + 1) + ' 张图片失败:', imgErr);
}
}
}
}
// ---- 统计工作表 ----
var wsSummary = workbook.addWorksheet('统计');
wsSummary.columns = [
{ header: '指标', key: 'label', width: 22 },
{ header: '数值', key: 'value', width: 14 },
{ header: '', key: 'extra', width: 14 }
];
// 标题
wsSummary.mergeCells('A1:C1');
var titleCell = wsSummary.getCell('A1');
titleCell.value = '场景资产分析统计';
titleCell.font = { bold: true, size: 14 };
titleCell.alignment = { horizontal: 'center' };
// 总览数据
wsSummary.addRow([]);
wsSummary.addRow(['资产总数', assets.length]);
var totalDays = assets.reduce(function(s, a) { return s + (parseFloat(a.days) || 0); }, 0);
wsSummary.addRow(['预估总人天(仅供参考)', totalDays]);
wsSummary.addRow([]);
wsSummary.addRow(['按类型分布', '', '']);
// 添加类型分布表头
var typeHeaderRow = wsSummary.addRow(['类型', '数量', '总人天']);
typeHeaderRow.eachCell(function(cell) {
cell.font = { bold: true };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
});
var typeMap = {};
assets.forEach(function(a) {
if (!typeMap[a.type]) typeMap[a.type] = { count: 0, days: 0 };
typeMap[a.type].count++;
typeMap[a.type].days += parseFloat(a.days) || 0;
});
Object.keys(typeMap).forEach(function(type) {
wsSummary.addRow([type, typeMap[type].count, typeMap[type].days.toFixed(1)]);
});
// ---- 生成并下载文件 ----
var buffer = await workbook.xlsx.writeBuffer();
var blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
var now = new Date();
var dateStr = now.getFullYear() + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0');
var fileName = '场景资产分析_' + dateStr + '.xlsx';
if (typeof saveAs !== 'undefined') {
saveAs(blob, fileName);
} else {
// 手动下载
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
// =================== SheetJS 回退导出(不含图片)===================
function exportToExcelFallback(assets, images, croppedThumbs) {
var headers = ['序号', '资产类型', '中文命名', '英文命名', '预估人天(仅供参考)', '位置截图', '位置描述', '制作注意事项(仅供参考)'];
var rows = assets.map(function(asset, index) {
var locDesc = asset.locationDesc || '-';
return [index + 1, asset.type, asset.nameCn, asset.nameEn, asset.days, '(见网页版)', locDesc, asset.notes];
});
var wb = XLSX.utils.book_new();
var ws = XLSX.utils.aoa_to_sheet([headers].concat(rows));
ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 20 }, { wch: 28 }, { wch: 18 }, { wch: 18 }, { wch: 20 }, { wch: 50 }];
XLSX.utils.book_append_sheet(wb, ws, '场景资产分析');
// 统计页
var summaryData = [['场景资产分析统计'], [], ['指标', '数值'],
['资产总数', assets.length],
['预估总人天(仅供参考)', assets.reduce(function(s, a) { return s + (parseFloat(a.days) || 0); }, 0)],
[], ['按类型分布'], ['类型', '数量', '总人天']
];
var typeMap = {};
assets.forEach(function(a) {
if (!typeMap[a.type]) typeMap[a.type] = { count: 0, days: 0 };
typeMap[a.type].count++;
typeMap[a.type].days += parseFloat(a.days) || 0;
});
Object.keys(typeMap).forEach(function(type) {
summaryData.push([type, typeMap[type].count, typeMap[type].days]);
});
var wsSummary = XLSX.utils.aoa_to_sheet(summaryData);
wsSummary['!cols'] = [{ wch: 20 }, { wch: 10 }, { wch: 10 }];
XLSX.utils.book_append_sheet(wb, wsSummary, '统计');
var now = new Date();
var dateStr = now.getFullYear() + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0');
XLSX.writeFile(wb, '场景资产分析_' + dateStr + '.xlsx');
}
// =================== CSV 导出 ===================
function exportToCsv(assets) {
var headers = ['序号', '资产类型', '中文命名', '英文命名', '预估人天(仅供参考)', '位置描述', '制作注意事项(仅供参考)'];
var rows = assets.map(function(asset, index) {
return [index + 1, csvEsc(asset.type), csvEsc(asset.nameCn), csvEsc(asset.nameEn), asset.days, csvEsc(asset.locationDesc || '-'), csvEsc(asset.notes)].join(',');
});
var BOM = '\uFEFF';
var csvContent = BOM + headers.join(',') + '\n' + rows.join('\n');
var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' });
var url = URL.createObjectURL(blob);
var now = new Date();
var dateStr = now.getFullYear() + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0');
var a = document.createElement('a');
a.href = url; a.download = '场景资产分析_' + dateStr + '.csv';
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
}
function csvEsc(str) {
if (!str) return '';
str = String(str);
return (str.indexOf(',') >= 0 || str.indexOf('"') >= 0 || str.indexOf('\n') >= 0) ? '"' + str.replace(/"/g, '""') + '"' : str;
}
</script>
<script>
/**
* Scene Asset Analyzer - 主逻辑
* 集成 AI 分析 + 位置截图裁切
*/
(function() {
var state = { uploadedImages: [], assets: [], croppedThumbs: {}, annotatedThumbs: {} };
function $(sel) { return document.querySelector(sel); }
document.addEventListener('DOMContentLoaded', function() {
var dom = {
uploadArea: $('#uploadArea'), fileInput: $('#fileInput'), selectBtn: $('#selectBtn'),
previewGrid: $('#previewGrid'), actionBar: $('#actionBar'), imageCount: $('#imageCount'),
analyzeBtn: $('#analyzeBtn'), uploadSection: $('#uploadSection'),
progressSection: $('#progressSection'), progressTitle: $('#progressTitle'),
progressText: $('#progressText'), progressFill: $('#progressFill'),
resultSection: $('#resultSection'),
assetCount: $('#assetCount'), assetTableBody: $('#assetTableBody'),
summaryCards: $('#summaryCards'), exportExcelBtn: $('#exportExcelBtn'),
exportCsvBtn: $('#exportCsvBtn'), addRowBtn: $('#addRowBtn'),
restartBtn: $('#restartBtn'),
// Help modal
helpBtn: $('#helpBtn'), helpModal: $('#helpModal'), helpModalClose: $('#helpModalClose'),
// Edit modal
editModal: $('#editModal'), editModalClose: $('#editModalClose'),
editIndex: $('#editIndex'), editType: $('#editType'),
editNameCn: $('#editNameCn'), editNameEn: $('#editNameEn'),
editDays: $('#editDays'), editNotes: $('#editNotes'),
editCancelBtn: $('#editCancelBtn'), editSaveBtn: $('#editSaveBtn'),
// AI settings modal
aiSettingsBtn: $('#aiSettingsBtn'), aiSettingsModal: $('#aiSettingsModal'),
aiSettingsModalClose: $('#aiSettingsModalClose'),
siliconflowApiKey: $('#siliconflowApiKey'), geminiApiKey: $('#geminiApiKey'),
siliconflowStatus: $('#siliconflowStatus'), geminiStatus: $('#geminiStatus'),
aiSettingsSaveBtn: $('#aiSettingsSaveBtn'), aiSettingsClearBtn: $('#aiSettingsClearBtn'),
// Model inline select
modelSelectInline: $('#modelSelectInline'), aiStatusInline: $('#aiStatusInline'),
// Image view modal
imageViewModal: $('#imageViewModal'), imageViewImg: $('#imageViewImg'),
imageViewClose: $('#imageViewClose'), imageViewTitle: $('#imageViewTitle'),
// Filter
filterType: $('#filterType'),
sortBy: $('#sortBy'), searchInput: $('#searchInput')
};
// =================== 初始化 ===================
initAISettings();
updateModelSelectStatus();
initIntroToggle();
// =================== 工具介绍收起/展开 ===================
function initIntroToggle() {
var toggleBtn = $('#introToggle');
var introBody = $('#introBody');
if (!toggleBtn || !introBody) return;
var header = toggleBtn.closest('.intro-header');
var collapsed = localStorage.getItem('intro_collapsed') === 'true';
if (collapsed) {
introBody.classList.add('collapsed');
toggleBtn.classList.add('collapsed');
}
function toggle() {
var isCollapsed = introBody.classList.toggle('collapsed');
toggleBtn.classList.toggle('collapsed');
localStorage.setItem('intro_collapsed', isCollapsed);
}
if (header) header.addEventListener('click', toggle);
}
// =================== 上传功能 ===================
dom.uploadArea.addEventListener('click', function(e) {
if (!e.target.closest('.remove-btn')) dom.fileInput.click();
});
dom.selectBtn.addEventListener('click', function(e) { e.stopPropagation(); dom.fileInput.click(); });
dom.fileInput.addEventListener('change', function(e) {
addImages(Array.from(e.target.files).filter(function(f){return f.type.startsWith('image/')}));
dom.fileInput.value = '';
});
dom.uploadArea.addEventListener('dragover', function(e) { e.preventDefault(); dom.uploadArea.classList.add('dragover'); });
dom.uploadArea.addEventListener('dragleave', function() { dom.uploadArea.classList.remove('dragover'); });
dom.uploadArea.addEventListener('drop', function(e) {
e.preventDefault(); dom.uploadArea.classList.remove('dragover');
addImages(Array.from(e.dataTransfer.files).filter(function(f){return f.type.startsWith('image/')}));
});
// =================== 分析按钮 ===================
dom.analyzeBtn.addEventListener('click', startAnalysis);
dom.exportExcelBtn.addEventListener('click', function() {
exportToExcel(state.assets, state.uploadedImages, state.annotatedThumbs).catch(function(err) {
console.error('Excel 导出失败:', err);
alert('Excel 导出失败: ' + err.message);
});
});
dom.exportCsvBtn.addEventListener('click', function() { exportToCsv(state.assets); });
dom.addRowBtn.addEventListener('click', addEmptyRow);
dom.restartBtn.addEventListener('click', restart);
// =================== 弹窗 ===================
// Help
dom.helpBtn.addEventListener('click', function() { dom.helpModal.style.display='flex'; });
dom.helpModalClose.addEventListener('click', function() { dom.helpModal.style.display='none'; });
dom.helpModal.addEventListener('click', function(e) { if(e.target===dom.helpModal) dom.helpModal.style.display='none'; });
// Edit
dom.editModalClose.addEventListener('click', closeEditModal);
dom.editCancelBtn.addEventListener('click', closeEditModal);
dom.editSaveBtn.addEventListener('click', saveEdit);
dom.editModal.addEventListener('click', function(e) { if(e.target===dom.editModal) closeEditModal(); });
// Image view
dom.imageViewClose.addEventListener('click', function() { dom.imageViewModal.style.display='none'; });
dom.imageViewModal.addEventListener('click', function(e) { if(e.target===dom.imageViewModal) dom.imageViewModal.style.display='none'; });
// AI Settings
dom.aiSettingsBtn.addEventListener('click', openAISettings);
dom.aiSettingsModalClose.addEventListener('click', function() { dom.aiSettingsModal.style.display='none'; });
dom.aiSettingsModal.addEventListener('click', function(e) { if(e.target===dom.aiSettingsModal) dom.aiSettingsModal.style.display='none'; });
dom.aiSettingsSaveBtn.addEventListener('click', saveAISettings);
dom.aiSettingsClearBtn.addEventListener('click', clearAISettings);
// Toggle visibility buttons
document.querySelectorAll('.toggle-visibility').forEach(function(btn) {
btn.addEventListener('click', function() {
var input = document.getElementById(btn.dataset.target);
if (input.type === 'password') {
input.type = 'text';
btn.textContent = '🙈';
} else {
input.type = 'password';
btn.textContent = '👁';
}
});
});
// Model select change
dom.modelSelectInline.addEventListener('change', updateModelSelectStatus);
// Filter & Sort
dom.filterType.addEventListener('change', renderTable);
dom.sortBy.addEventListener('change', renderTable);
dom.searchInput.addEventListener('input', renderTable);
// =================== 图片管理 ===================
function addImages(files) {
files.forEach(function(file) {
state.uploadedImages.push({ file: file, url: URL.createObjectURL(file), name: file.name });
});
renderPreview(); updateActionBar();
}
function renderPreview() {
dom.previewGrid.innerHTML = state.uploadedImages.map(function(img, i) {
return '<div class="preview-item"><img src="'+img.url+'" alt="'+img.name+'" /><button class="remove-btn" data-index="'+i+'">&times;</button><div class="image-label">'+img.name+'</div></div>';
}).join('');
dom.previewGrid.querySelectorAll('.remove-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
var idx = parseInt(btn.dataset.index);
URL.revokeObjectURL(state.uploadedImages[idx].url);
state.uploadedImages.splice(idx, 1);
renderPreview(); updateActionBar();
});
});
}
function updateActionBar() {
var count = state.uploadedImages.length;
dom.imageCount.textContent = count;
dom.actionBar.style.display = count > 0 ? 'flex' : 'none';
}
// =================== AI 设置 ===================
function initAISettings() {
dom.siliconflowApiKey.value = AIAnalyzer.getSiliconFlowKey();
dom.geminiApiKey.value = AIAnalyzer.getGeminiKey();
updateApiKeyStatuses();
}
function updateApiKeyStatuses() {
var sfKey = AIAnalyzer.getSiliconFlowKey();
var gmKey = AIAnalyzer.getGeminiKey();
dom.siliconflowStatus.textContent = sfKey ? '✅ 已配置' : '⚠️ 未配置';
dom.siliconflowStatus.className = 'api-key-status ' + (sfKey ? 'configured' : 'not-configured');
dom.geminiStatus.textContent = gmKey ? '✅ 已配置' : '⚠️ 未配置';
dom.geminiStatus.className = 'api-key-status ' + (gmKey ? 'configured' : 'not-configured');
}
function openAISettings() {
dom.siliconflowApiKey.value = AIAnalyzer.getSiliconFlowKey();
dom.geminiApiKey.value = AIAnalyzer.getGeminiKey();
updateApiKeyStatuses();
dom.aiSettingsModal.style.display = 'flex';
}
function saveAISettings() {
AIAnalyzer.saveSiliconFlowKey(dom.siliconflowApiKey.value.trim());
AIAnalyzer.saveGeminiKey(dom.geminiApiKey.value.trim());
updateApiKeyStatuses();
updateModelSelectStatus();
dom.aiSettingsModal.style.display = 'none';
showToast('AI 设置已保存');
}
function clearAISettings() {
if (confirm('确定要清除所有 API Key 吗?')) {
AIAnalyzer.clearAllKeys();
dom.siliconflowApiKey.value = '';
dom.geminiApiKey.value = '';
updateApiKeyStatuses();
updateModelSelectStatus();
showToast('已清除所有 API Key');
}
}
function updateModelSelectStatus() {
if (!dom.modelSelectInline) return;
var model = dom.modelSelectInline.value;
if (AIAnalyzer.isModelAvailable(model)) {
dom.aiStatusInline.textContent = '✅ Key 已配置';
dom.aiStatusInline.className = 'ai-status available';
} else {
dom.aiStatusInline.textContent = '⚠️ 请先配置 API Key';
dom.aiStatusInline.className = 'ai-status unavailable';
}
}
// =================== 分析流程 ===================
function sleep(ms) { return new Promise(function(r){setTimeout(r,ms)}); }
async function startAnalysis() {
if (state.uploadedImages.length === 0) return;
var selectedModel = dom.modelSelectInline ? dom.modelSelectInline.value : 'qwen3-vl-32b';
// 检查 AI 模型可用性
if (!AIAnalyzer.isModelAvailable(selectedModel)) {
alert('所选 AI 模型的 API Key 未配置,请先在 AI 设置中配置对应的 API Key。');
openAISettings();
return;
}
dom.uploadSection.style.display = 'none';
dom.progressSection.style.display = 'flex';
dom.resultSection.style.display = 'none';
state.croppedThumbs = {};
state.annotatedThumbs = {};
try {
// AI 分析
await aiAnalysis(selectedModel);
// 生成位置截图缩略图
dom.progressTitle.textContent = '正在生成位置截图...';
dom.progressText.textContent = '裁切资产对应区域';
dom.progressFill.style.width = '90%';
await generateCroppedThumbs();
dom.progressFill.style.width = '100%';
dom.progressText.textContent = '分析完成!';
await sleep(300);
dom.progressSection.style.display = 'none';
dom.resultSection.style.display = 'block';
renderTable(); renderSummary(); renderPipeline(); renderAdvice();
} catch(err) {
console.error('分析失败:', err);
dom.progressSection.style.display = 'none';
dom.uploadSection.style.display = 'block';
var errMsg = '分析失败:' + err.message + '\n\n请检查 API Key 是否正确,或尝试其他模型。\n(详细信息请打开浏览器 F12 控制台查看)';
alert(errMsg);
}
}
async function aiAnalysis(model) {
dom.progressTitle.textContent = '正在使用 AI 分析场景资产...';
dom.progressText.textContent = '准备中...';
dom.progressFill.style.width = '10%';
var sceneTypeEl = document.getElementById('sceneTypeSelect');
var sceneType = sceneTypeEl ? sceneTypeEl.value : 'auto';
var progressHandler = function(msg) {
dom.progressText.textContent = msg;
// 逐步推进进度
var current = parseFloat(dom.progressFill.style.width) || 10;
if (current < 80) {
dom.progressFill.style.width = Math.min(80, current + 15) + '%';
}
};
state.assets = await AIAnalyzer.analyze(model, state.uploadedImages, sceneType, progressHandler);
dom.progressText.textContent = 'AI 分析完成,正在整理结果...';
dom.progressFill.style.width = '85%';
await sleep(300);
}
async function generateCroppedThumbs() {
for (var i = 0; i < state.assets.length; i++) {
var asset = state.assets[i];
var imgIdx = asset.thumbIndex || 0;
var imgUrl = state.uploadedImages[imgIdx] ? state.uploadedImages[imgIdx].url : null;
if (imgUrl && asset.locationRect) {
// 生成裁切缩略图
var cropped = await AIAnalyzer.cropImageToCanvas(imgUrl, asset.locationRect);
if (cropped) state.croppedThumbs[i] = cropped;
// 生成标注版全图
var annotated = await AIAnalyzer.annotateImageWithRect(imgUrl, asset.locationRect);
if (annotated) state.annotatedThumbs[i] = annotated;
}
}
}
// =================== 表格渲染 ===================
function getTypeClass(type) {
var map = {'建筑结构':'building','机械设备':'mechanical','管道/线缆':'pipe','道具/摆件':'prop','灯光/照明':'light','植被/自然':'vegetation','地面/地形':'ground','交通工具':'vehicle','UI/标识':'ui','特效/粒子':'fx','贴图/材质':'texture'};
return map[type] || 'other';
}
function getFilteredAssets() {
var typeFilter = dom.filterType ? dom.filterType.value : 'all';
var sortVal = dom.sortBy ? dom.sortBy.value : 'default';
var searchVal = (dom.searchInput ? dom.searchInput.value : '').toLowerCase().trim();
var filtered = state.assets.map(function(a, i) {
return { asset: a, originalIndex: i };
});
if (typeFilter !== 'all') {
filtered = filtered.filter(function(item) { return item.asset.type === typeFilter; });
}
if (searchVal) {
filtered = filtered.filter(function(item) {
return item.asset.nameCn.toLowerCase().indexOf(searchVal) >= 0 ||
item.asset.nameEn.toLowerCase().indexOf(searchVal) >= 0;
});
}
if (sortVal === 'days-desc') {
filtered.sort(function(a,b) { return (parseFloat(b.asset.days)||0) - (parseFloat(a.asset.days)||0); });
} else if (sortVal === 'days-asc') {
filtered.sort(function(a,b) { return (parseFloat(a.asset.days)||0) - (parseFloat(b.asset.days)||0); });
} else if (sortVal === 'type') {
filtered.sort(function(a,b) { return a.asset.type.localeCompare(b.asset.type); });
}
return filtered;
}
function renderTable() {
dom.assetCount.textContent = state.assets.length;
var filtered = getFilteredAssets();
dom.assetTableBody.innerHTML = filtered.map(function(item, displayIdx) {
var asset = item.asset;
var origIdx = item.originalIndex;
// 位置截图:优先使用裁切图,否则使用原图缩略
var thumbHtml = '';
if (state.croppedThumbs[origIdx]) {
thumbHtml = '<div class="thumb-cell">' +
'<img class="thumb-img thumb-cropped" src="'+state.croppedThumbs[origIdx]+'" alt="位置截图" data-orig-index="'+origIdx+'" />' +
(asset.locationDesc ? '<span class="location-desc">'+asset.locationDesc+'</span>' : '') +
'</div>';
} else {
var thumbSrc = (asset.thumbIndex!==undefined && state.uploadedImages[asset.thumbIndex]) ? state.uploadedImages[asset.thumbIndex].url : '';
thumbHtml = thumbSrc ?
'<div class="thumb-cell"><img class="thumb-img" src="'+thumbSrc+'" alt="截图" data-orig-index="'+origIdx+'" />' +
(asset.locationDesc ? '<span class="location-desc">'+asset.locationDesc+'</span>' : '') +
'</div>' :
'<span style="color:var(--text-muted)">-</span>';
}
// 复用与贴图标识
var badgesHtml = '';
if (asset.reusable) {
badgesHtml += '<span class="badge-reusable" title="可复用资产,场景中约' + (asset.reusableCount || '多') + '个实例">🔄 复用×' + (asset.reusableCount || '?') + '</span>';
}
if (asset.tilingType && asset.tilingType !== '无' && asset.tilingType !== '') {
badgesHtml += '<span class="badge-tiling" title="' + asset.tilingType + '贴图">🔲 ' + asset.tilingType + '</span>';
}
return '<tr>' +
'<td class="td-center">'+(displayIdx+1)+'</td>' +
'<td><span class="type-tag '+getTypeClass(asset.type)+'">'+asset.type+'</span></td>' +
'<td><strong>'+asset.nameCn+'</strong>' + (badgesHtml ? '<div class="asset-badges">' + badgesHtml + '</div>' : '') + '</td>' +
'<td><code style="font-size:0.8rem;color:var(--text-secondary)">'+asset.nameEn+'</code></td>' +
'<td class="td-center"><span class="days-value">'+(asset.days||'-')+'</span></td>' +
'<td>'+thumbHtml+'</td>' +
'<td><div class="notes-cell">'+asset.notes+'</div></td>' +
'<td><div class="action-btns"><button class="btn btn-sm btn-outline edit-row-btn" data-index="'+origIdx+'">编辑</button><button class="btn btn-sm btn-danger delete-row-btn" data-index="'+origIdx+'">删除</button></div></td>' +
'</tr>';
}).join('');
// 编辑/删除按钮
dom.assetTableBody.querySelectorAll('.edit-row-btn').forEach(function(btn) {
btn.addEventListener('click', function() { openEditModal(parseInt(btn.dataset.index)); });
});
dom.assetTableBody.querySelectorAll('.delete-row-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
if (confirm('确定要删除这条资产记录吗?')) {
state.assets.splice(parseInt(btn.dataset.index), 1);
renderTable(); renderSummary();
}
});
});
// 缩略图点击查看大图
dom.assetTableBody.querySelectorAll('.thumb-img').forEach(function(img) {
img.addEventListener('click', function() {
var origIdx = parseInt(img.dataset.origIndex);
// 如果有标注版大图,显示标注版;否则显示原图
if (state.annotatedThumbs[origIdx]) {
dom.imageViewImg.src = state.annotatedThumbs[origIdx];
dom.imageViewTitle.textContent = '资产位置标注 - ' + state.assets[origIdx].nameCn;
} else {
var asset = state.assets[origIdx];
var imgUrl = state.uploadedImages[asset.thumbIndex] ? state.uploadedImages[asset.thumbIndex].url : img.src;
dom.imageViewImg.src = imgUrl;
dom.imageViewTitle.textContent = '截图查看';
}
dom.imageViewModal.style.display = 'flex';
});
});
}
function renderSummary() {
var total = state.assets.length;
var totalDays = state.assets.reduce(function(s,a){return s+(parseFloat(a.days)||0)},0);
// 统计类型数
var typeSet = {};
state.assets.forEach(function(a) { typeSet[a.type] = true; });
var typeCount = Object.keys(typeSet).length;
// 复用资产数
var reusableCount = state.assets.filter(function(a) { return a.reusable; }).length;
dom.summaryCards.innerHTML =
'<div class="summary-card"><div class="icon-box orange">📦</div><div><div class="stat-value">'+total+'</div><div class="stat-label">识别资产总数</div></div></div>' +
'<div class="summary-card"><div class="icon-box blue">🏷️</div><div><div class="stat-value">'+typeCount+'</div><div class="stat-label">资产类型数</div></div></div>' +
'<div class="summary-card"><div class="icon-box yellow">🔄</div><div><div class="stat-value">'+reusableCount+'</div><div class="stat-label">可复用资产</div></div></div>' +
'<div class="summary-card"><div class="icon-box green">📅</div><div><div class="stat-value">'+totalDays.toFixed(1)+'</div><div class="stat-label">预估总人天(仅供参考)</div></div></div>';
}
// =================== 管线估算 ===================
function renderPipeline() {
var pipelineCards = document.getElementById('pipelineCards');
if (!pipelineCards || state.assets.length === 0) return;
var totalDays = state.assets.reduce(function(s,a){return s+(parseFloat(a.days)||0)},0);
var total = state.assets.length;
// 按类型分组
var typeGroups = {};
state.assets.forEach(function(a) {
if (!typeGroups[a.type]) typeGroups[a.type] = { items: [], days: 0 };
typeGroups[a.type].items.push(a);
typeGroups[a.type].days += parseFloat(a.days) || 0;
});
// 估算并行制作人数(假设2-3人团队)
var teamSize = Math.max(2, Math.ceil(total / 5));
var parallelDays = Math.ceil(totalDays / teamSize);
var html = '<div class="pipeline-grid">';
// 按类型展示各组
var typeKeys = Object.keys(typeGroups);
var phaseClasses = ['phase-1', 'phase-2', 'phase-3'];
typeKeys.forEach(function(type, idx) {
var group = typeGroups[type];
var phaseClass = phaseClasses[idx % phaseClasses.length];
html += '<div class="pipeline-card ' + phaseClass + '">' +
'<div class="pipeline-phase-badge">' + type + '</div>' +
'<h4>' + group.items.length + ' 项资产</h4>' +
'<div class="pipeline-stat">' +
'预估人天:<span class="pipeline-days">' + group.days.toFixed(1) + '</span> 天' +
'</div>' +
'<div class="pipeline-items">' +
group.items.slice(0, 4).map(function(a){
return '<span class="pipeline-item-tag">' + a.nameCn + '</span>';
}).join('') +
(group.items.length > 4 ? '<span class="pipeline-item-more">+' + (group.items.length - 4) + ' 更多</span>' : '') +
'</div>' +
'</div>';
});
// 总览卡片
html += '<div class="pipeline-card phase-summary">' +
'<div class="pipeline-phase-badge">总览</div>' +
'<h4>制作管线总结</h4>' +
'<div class="pipeline-stat">' +
'建议团队规模:<span class="pipeline-count">' + teamSize + '</span> 人' +
'</div>' +
'<div class="pipeline-stat">' +
'总计人天:<span class="pipeline-days">' + totalDays.toFixed(1) + '</span> 天' +
'</div>' +
'<div class="pipeline-stat">' +
'预估并行工期:<span class="pipeline-days">~' + parallelDays + '</span> 天' +
'</div>' +
'</div>';
html += '</div>';
pipelineCards.innerHTML = html;
}
// =================== 美术制作建议 ===================
function renderAdvice() {
var adviceCards = document.getElementById('adviceCards');
if (!adviceCards || state.assets.length === 0) return;
// 按类型分组统计
var typeGroups = {};
state.assets.forEach(function(a) {
if (!typeGroups[a.type]) typeGroups[a.type] = [];
typeGroups[a.type].push(a);
});
var adviceList = [];
// 通用建议
adviceList.push({
icon: '🎯',
title: '制作规范建议',
content: '建议统一建模比例为 1 Unit = 1cm,UV密度统一为 10.24px/cm(即 1024px 贴图对应 100cm 物体)。所有模型原点放在底部中心,法线方向统一朝外。命名规范使用英文 + 下划线格式(如 ' + (state.assets[0] ? state.assets[0].nameEn : 'Asset_Name') + ')。'
});
// 基于资产数量的建议
if (state.assets.length > 10) {
adviceList.push({
icon: '📦',
title: '模块化与复用策略',
content: '共识别 ' + state.assets.length + ' 项资产,建议优先建立资产模块化体系。将重复出现的元素(管道接口、栏杆扶手、螺栓等)制作为可复用的子部件,通过实例化减少内存占用。相似资产可共享材质球和贴图 Atlas。'
});
}
// 基于类型的建议
Object.keys(typeGroups).forEach(function(type) {
var group = typeGroups[type];
if (group.length >= 2) {
var typeAdvice = getTypeSpecificAdvice(type, group);
if (typeAdvice) adviceList.push(typeAdvice);
}
});
// LOD 建议
var highDaysAssets = state.assets.filter(function(a){return (parseFloat(a.days)||0) >= 2});
if (highDaysAssets.length > 0) {
adviceList.push({
icon: '🔍',
title: 'LOD 层级建议',
content: '其中 ' + highDaysAssets.length + ' 项复杂资产(≥2人天)建议制作至少 2 级 LOD:LOD0 为完整模型用于近景,LOD1 减面 50% 用于中景。对于特别复杂的资产可增加 LOD2(减面 75%)用于远景。'
});
}
// 材质贴图建议
adviceList.push({
icon: '🎨',
title: '材质与贴图建议',
content: '建议使用 PBR 金属度/粗糙度工作流。主要资产贴图分辨率建议:大型建筑 2048×2048,中型道具 1024×1024,小型摆件 512×512。尽量使用 Trim Sheet 和 Tiling 材质减少贴图数量,提高纹理利用率。'
});
var html = '<div class="advice-grid">';
adviceList.forEach(function(advice) {
html += '<div class="advice-card">' +
'<div class="advice-icon">' + advice.icon + '</div>' +
'<div class="advice-content">' +
'<h4>' + advice.title + '</h4>' +
'<p>' + advice.content + '</p>' +
'</div>' +
'</div>';
});
html += '</div>';
adviceCards.innerHTML = html;
}
function getTypeSpecificAdvice(type, assets) {
var count = assets.length;
var adviceMap = {
'建筑结构': { icon: '🏗️', title: '建筑结构制作要点 (' + count + '项)', content: '建筑类资产数量较多,建议先搭建基础模块(墙面、地板、天花板、柱子),再通过组合生成完整建筑。注意建筑的结构合理性,门窗洞口需预留足够的倒角细节。大型建筑考虑分区加载策略。' },
'机械设备': { icon: '⚙️', title: '机械设备制作要点 (' + count + '项)', content: '机械设备注意运动部件的骨骼绑定和动画预留,管线连接处要做到无缝衔接。金属质感通过 Roughness 变化体现使用痕迹,设备铭牌和指示灯等小细节可用贴花处理。' },
'管道/线缆': { icon: '🔧', title: '管道线缆制作要点 (' + count + '项)', content: '管道建议使用样条线建模,保证弯曲处平滑自然。不同管径使用不同颜色区分(参考工业标准色)。线缆可使用截面拉伸建模,注意自然垂坠效果。接口法兰盘和阀门可做为独立子部件复用。' },
'道具/摆件': { icon: '🎭', title: '道具摆件制作要点 (' + count + '项)', content: '道具资产要注意做旧效果层次(灰尘、锈迹、磨损),通过 Substance Painter 的智能材质快速实现。小型道具可适当简化背面面数。可交互道具需预留碰撞体和交互触发区域。' },
'灯光/照明': { icon: '💡', title: '灯光照明制作要点 (' + count + '项)', content: '灯具模型的灯管/灯泡部分使用自发光材质,注意发光强度和色温的合理设置。灯罩部分使用半透明材质模拟真实的光线散射效果。灯具的光源组件与模型分离管理。' },
'植被/自然': { icon: '🌿', title: '植被自然制作要点 (' + count + '项)', content: '植被建议使用 SpeedTree 或类似工具制作,确保有合理的 LOD 和风力动画。树叶使用双面材质 + 次表面散射。草地和灌木可使用交叉面片技术,注意 Alpha Test 的边缘抗锯齿处理。' },
'地面/地形': { icon: '🗺️', title: '地面地形制作要点 (' + count + '项)', content: '地面材质建议使用可平铺的 Tiling 材质,通过高度混合实现不同材质的自然过渡。注意地面材质在不同光照条件下的表现,建议制作 Macro Normal 减少远处的重复感。' },
'交通工具': { icon: '🚗', title: '交通工具制作要点 (' + count + '项)', content: '交通工具属于高复杂度资产,建议采用高模→低模→烘焙的完整流程。车身需要注意反射效果和车漆多层材质(底漆、色漆、清漆),车灯使用自发光+折射材质,轮胎使用独立的橡胶材质。内饰如可见需单独制作。全流程预估12-20人天。' },
'贴图/材质': { icon: '🎨', title: '贴图材质制作要点 (' + count + '项)', content: '对于二方/四方连续贴图,需要在 Substance Designer 中制作确保无缝平铺。建议制作基础贴图后通过参数化调整生成变体。注意在引擎中设置正确的 Tiling 参数和世界空间对齐,避免UV接缝可见。' }
};
return adviceMap[type] || null;
}
// =================== 编辑功能 ===================
function addEmptyRow() {
state.assets.push({ type:'其他', nameCn:'新资产', nameEn:'New_Asset', days:1, thumbIndex:0, notes:'请编辑填写详细信息', locationDesc:'', locationRect:null });
renderTable(); renderSummary();
}
function openEditModal(index) {
var asset = state.assets[index];
dom.editIndex.value = index;
dom.editType.value = asset.type;
dom.editNameCn.value = asset.nameCn;
dom.editNameEn.value = asset.nameEn;
dom.editDays.value = asset.days;
dom.editNotes.value = asset.notes;
dom.editModal.style.display = 'flex';
}
function closeEditModal() { dom.editModal.style.display = 'none'; }
function saveEdit() {
var idx = parseInt(dom.editIndex.value);
var asset = state.assets[idx];
asset.type = dom.editType.value;
asset.nameCn = dom.editNameCn.value;
asset.nameEn = dom.editNameEn.value;
asset.days = parseFloat(dom.editDays.value) || 0;
asset.notes = dom.editNotes.value;
closeEditModal(); renderTable(); renderSummary();
}
function restart() {
state.uploadedImages.forEach(function(img){URL.revokeObjectURL(img.url)});
state.uploadedImages = []; state.assets = [];
state.croppedThumbs = {}; state.annotatedThumbs = {};
dom.previewGrid.innerHTML = '';
dom.actionBar.style.display = 'none';
dom.progressFill.style.width = '0%';
dom.uploadSection.style.display = 'block';
dom.progressSection.style.display = 'none';
dom.resultSection.style.display = 'none';
}
// =================== Toast ===================
function showToast(msg) {
var toast = document.createElement('div');
toast.className = 'toast-msg';
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(function() { toast.classList.add('show'); }, 10);
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { document.body.removeChild(toast); }, 300);
}, 2000);
}
});
})();
</script>
</body>
</html>