| <!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> |
| |
| |
| |
| |
| |
| *, *::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 { |
| 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; |
| } |
| |
| |
| .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 { |
| max-width: 1400px; |
| margin: 0 auto; |
| padding: 32px 24px; |
| } |
| |
| |
| .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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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-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 { |
| 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 { |
| 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; |
| } |
| |
| |
| |
| |
| |
| |
| .days-value { |
| font-weight: 700; |
| color: var(--primary); |
| font-size: 1rem; |
| } |
| |
| |
| .notes-cell { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| line-height: 1.5; |
| max-width: 300px; |
| } |
| |
| |
| .action-btns { |
| display: flex; |
| gap: 6px; |
| } |
| |
| |
| .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 { |
| margin-top: 24px; |
| text-align: center; |
| } |
| |
| |
| .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-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 .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 { |
| 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 { |
| 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 { |
| 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-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-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 { |
| 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-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-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-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 { |
| 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 { |
| 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; |
| } |
| |
| |
| @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> |
|
|
| |
| <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">×</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">×</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">×</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">×</button> |
| </div> |
| <div class="modal-body image-view-body"> |
| <img id="imageViewImg" src="" alt="截图" /> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| |
| |
| |
| |
| var AIAnalyzer = (function() { |
| var STORAGE_KEY_SF = 'scene_analyzer_sf_api_key'; |
| var STORAGE_KEY_GEMINI = 'scene_analyzer_gemini_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; |
| } |
| |
| |
| 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); |
| }); |
| } |
| |
| |
| 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格式正确无误'; |
| } |
| |
| |
| 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 返回数据格式异常'); |
| } |
| |
| |
| var msg = data.choices[0].message; |
| var content = msg.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); |
| } |
| |
| |
| 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); |
| |
| 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) { |
| |
| 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 返回数据格式异常'); |
| } |
| |
| |
| |
| |
| 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; |
| } |
| } |
| |
| 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); |
| } |
| |
| |
| function parseAIResponse(rawText, imageCount) { |
| console.log('AI 原始返回内容(前1000字符):', rawText.substring(0, 1000)); |
| |
| |
| var jsonStr = ''; |
| |
| |
| var jsonMatch = rawText.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/); |
| if (jsonMatch) { |
| jsonStr = jsonMatch[1].trim(); |
| console.log('从 code block 中提取到 JSON'); |
| } |
| |
| |
| if (!jsonStr) { |
| jsonStr = rawText.trim(); |
| } |
| |
| |
| 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)); |
| |
| |
| |
| 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) { |
| |
| 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 返回的不是数组格式'); |
| } |
| } |
| |
| |
| 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; |
| |
| |
| var maxVal = Math.max(x, y, x + w, y + h); |
| |
| if (maxVal > 1) { |
| if (maxVal <= 100) { |
| |
| x = x / 100; y = y / 100; w = w / 100; h = h / 100; |
| } else if (maxVal <= 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; |
| } |
| } |
| |
| |
| |
| if (w > x && h > y && w > 0.3 && h > 0.3) { |
| |
| 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 || ''; |
| |
| |
| 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; |
| |
| |
| 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'); |
| |
| |
| 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> |
| |
| |
| |
| |
| |
| |
| function dataUrlToBase64(dataUrl) { |
| if (!dataUrl) return null; |
| var idx = dataUrl.indexOf(','); |
| return idx >= 0 ? dataUrl.substring(idx + 1) : dataUrl; |
| } |
| |
| |
| function imageUrlToBase64(url) { |
| return new Promise(function(resolve) { |
| var img = new Image(); |
| img.crossOrigin = 'anonymous'; |
| img.onload = function() { |
| var canvas = document.createElement('canvas'); |
| |
| 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) { |
| |
| if (typeof ExcelJS !== 'undefined') { |
| try { |
| await exportToExcelWithExcelJS(assets, images, croppedThumbs); |
| return; |
| } catch(err) { |
| console.error('ExcelJS 导出失败,回退到 SheetJS:', err); |
| } |
| } |
| |
| |
| if (typeof XLSX !== 'undefined') { |
| exportToExcelFallback(assets, images, croppedThumbs); |
| return; |
| } |
| |
| alert('Excel 导出库尚未加载完成,请稍后再试或使用 CSV 导出'); |
| } |
| |
| |
| 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; |
| var IMG_WIDTH = 320; |
| var IMG_HEIGHT = 140; |
| |
| |
| for (var i = 0; i < assets.length; i++) { |
| var asset = assets[i]; |
| var rowIndex = i + 2; |
| |
| 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) { |
| |
| 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); |
| } |
| } |
| |
| |
| 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'); |
| } |
| |
| |
| 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> |
| |
| |
| |
| |
| (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'), |
| |
| helpBtn: $('#helpBtn'), helpModal: $('#helpModal'), helpModalClose: $('#helpModalClose'), |
| |
| editModal: $('#editModal'), editModalClose: $('#editModalClose'), |
| editIndex: $('#editIndex'), editType: $('#editType'), |
| editNameCn: $('#editNameCn'), editNameEn: $('#editNameEn'), |
| editDays: $('#editDays'), editNotes: $('#editNotes'), |
| editCancelBtn: $('#editCancelBtn'), editSaveBtn: $('#editSaveBtn'), |
| |
| aiSettingsBtn: $('#aiSettingsBtn'), aiSettingsModal: $('#aiSettingsModal'), |
| aiSettingsModalClose: $('#aiSettingsModalClose'), |
| siliconflowApiKey: $('#siliconflowApiKey'), geminiApiKey: $('#geminiApiKey'), |
| siliconflowStatus: $('#siliconflowStatus'), geminiStatus: $('#geminiStatus'), |
| aiSettingsSaveBtn: $('#aiSettingsSaveBtn'), aiSettingsClearBtn: $('#aiSettingsClearBtn'), |
| |
| modelSelectInline: $('#modelSelectInline'), aiStatusInline: $('#aiStatusInline'), |
| |
| imageViewModal: $('#imageViewModal'), imageViewImg: $('#imageViewImg'), |
| imageViewClose: $('#imageViewClose'), imageViewTitle: $('#imageViewTitle'), |
| |
| 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); |
| |
| |
| |
| 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'; }); |
| |
| |
| 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(); }); |
| |
| |
| 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'; }); |
| |
| |
| 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); |
| |
| |
| 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 = '👁'; |
| } |
| }); |
| }); |
| |
| |
| dom.modelSelectInline.addEventListener('change', updateModelSelectStatus); |
| |
| |
| 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+'">×</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'; |
| } |
| |
| |
| 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'; |
| |
| |
| 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 { |
| |
| 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; |
| }); |
| |
| |
| 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); |
| } |
| }); |
| |
| |
| 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'; |
| } |
| |
| |
| 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> |
|
|