3v324v23 commited on
Commit
8a67525
·
0 Parent(s):

Enhance functionality: Excel import, bug fixes, UI improvements

Browse files
Files changed (5) hide show
  1. Dockerfile +18 -0
  2. README.md +49 -0
  3. app.py +232 -0
  4. requirements.txt +3 -0
  5. templates/index.html +455 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Create a non-root user for Hugging Face Spaces
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
+ WORKDIR /app
9
+
10
+ COPY --chown=user:user requirements.txt requirements.txt
11
+ RUN pip install --no-cache-dir --upgrade pip && \
12
+ pip install --no-cache-dir -r requirements.txt
13
+
14
+ COPY --chown=user:user . /app
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Smart Load Packer
3
+ emoji: 📦
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ short_description: 智能装箱大师 - 3D可视化装箱模拟与算法引擎
9
+ pinned: false
10
+ ---
11
+
12
+ # 智能装箱大师 (Smart Load Packer)
13
+
14
+ 这是一个基于 3D 装箱算法(3D Bin Packing)的可视化模拟工具,旨在帮助物流、仓储和电商企业优化货物装载,提高空间利用率,降低运输成本。
15
+
16
+ ## 核心功能
17
+
18
+ - **智能装箱算法**: 采用启发式 3D 装箱策略,自动计算最优装载方案。
19
+ - **3D 可视化**: 基于 Three.js 的实时 3D 渲染,直观展示装箱结果,支持旋转、缩放、平移。
20
+ - **多规格货物支持**: 支持自定义多种尺寸、重量、颜色的货物。
21
+ - **容器定制**: 支持自定义集装箱/货车/纸箱的尺寸和最大载重。
22
+ - **数据分析**: 实时计算空间利用率、装载数量、总重量等关键指标。
23
+ - **装箱清单**: 生成详细的装箱坐标清单 (Manifest)。
24
+
25
+ ## 技术栈
26
+
27
+ - **后端**: Python (Flask) - 处理装箱核心算法逻辑
28
+ - **前端**: Vue 3 + Tailwind CSS - 响应式交互界面
29
+ - **可视化**: Three.js - 3D 场景渲染
30
+ - **部署**: Docker - 容器化部署
31
+
32
+ ## 商业价值
33
+
34
+ 该项目可应用于:
35
+ 1. **物流运输**: 优化卡车/集装箱装载,减少车辆使用。
36
+ 2. **仓储管理**: 优化货架摆放和纸箱打包。
37
+ 3. **电商发货**: 自动推荐包装箱尺寸,减少包材浪费。
38
+
39
+ ## 本地运行
40
+
41
+ ```bash
42
+ # 构建镜像
43
+ docker build -t smart-load-packer .
44
+
45
+ # 运行容器
46
+ docker run -p 7860:7860 smart-load-packer
47
+ ```
48
+
49
+ 访问: http://localhost:7860
app.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import time
4
+ import uuid
5
+ import pandas as pd
6
+ from flask import Flask, render_template, request, jsonify
7
+
8
+ app = Flask(__name__)
9
+ app.config['SECRET_KEY'] = 'smart-load-packer-secret'
10
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload
11
+
12
+ # --- Error Handlers ---
13
+ @app.errorhandler(500)
14
+ def internal_error(error):
15
+ return jsonify({'status': 'error', 'message': 'Internal Server Error', 'details': str(error)}), 500
16
+
17
+ @app.errorhandler(413)
18
+ def request_entity_too_large(error):
19
+ return jsonify({'status': 'error', 'message': 'File Too Large'}), 413
20
+
21
+ # --- 3D Packing Algorithm (Heuristic) ---
22
+
23
+ class Item:
24
+ def __init__(self, id, name, w, h, d, color, weight):
25
+ self.id = id
26
+ self.name = name
27
+ self.w = float(w)
28
+ self.h = float(h)
29
+ self.d = float(d)
30
+ self.color = color
31
+ self.weight = float(weight)
32
+ self.x = 0
33
+ self.y = 0
34
+ self.z = 0
35
+ self.volume = self.w * self.h * self.d
36
+
37
+ def to_dict(self):
38
+ return {
39
+ 'id': self.id,
40
+ 'name': self.name,
41
+ 'w': self.w, 'h': self.h, 'd': self.d,
42
+ 'x': self.x, 'y': self.y, 'z': self.z,
43
+ 'color': self.color,
44
+ 'weight': self.weight
45
+ }
46
+
47
+ class Bin:
48
+ def __init__(self, w, h, d, max_weight):
49
+ self.w = float(w)
50
+ self.h = float(h)
51
+ self.d = float(d)
52
+ self.max_weight = float(max_weight)
53
+ self.items = []
54
+ self.unpacked_items = []
55
+ # Potential placement points (x, y, z)
56
+ # Start with origin
57
+ self.points = [(0, 0, 0)]
58
+
59
+ def intersect(self, i1, x2, y2, z2, w2, h2, d2):
60
+ return (
61
+ i1.x < x2 + w2 and i1.x + i1.w > x2 and
62
+ i1.y < y2 + h2 and i1.y + i1.h > y2 and
63
+ i1.z < z2 + d2 and i1.z + i1.d > z2
64
+ )
65
+
66
+ def can_fit(self, item, x, y, z):
67
+ # Check boundaries
68
+ if x + item.w > self.w or y + item.h > self.h or z + item.d > self.d:
69
+ return False
70
+ # Check intersections
71
+ for i in self.items:
72
+ if self.intersect(i, x, y, z, item.w, item.h, item.d):
73
+ return False
74
+ return True
75
+
76
+ def pack(self, items_to_pack):
77
+ # Sort items by volume desc, then max dimension
78
+ # Heuristic: Bigger items first
79
+ items_to_pack.sort(key=lambda x: (x.volume, max(x.w, x.h, x.d)), reverse=True)
80
+
81
+ for item in items_to_pack:
82
+ placed = False
83
+ # Sort points to try to pack bottom-up, back-left
84
+ # Priority: Y (height/vertical) asc, Z (depth) asc, X (width) asc
85
+ # Assuming Y is vertical axis
86
+ self.points.sort(key=lambda p: (p[1], p[2], p[0]))
87
+
88
+ for i, (px, py, pz) in enumerate(self.points):
89
+ # Try standard orientation
90
+ if self.can_fit(item, px, py, pz):
91
+ item.x, item.y, item.z = px, py, pz
92
+ self.items.append(item)
93
+ self._add_new_points(item)
94
+ placed = True
95
+ break
96
+
97
+ # Try rotation (swap W and D) - simple rotation on floor
98
+ # Swap W and D
99
+ if self.can_fit(item, px, py, pz):
100
+ # Wait, I need to actually change dimensions to test rotation
101
+ # But let's stick to simple non-rotation for MVP to avoid complexity
102
+ pass
103
+
104
+ if not placed:
105
+ self.unpacked_items.append(item)
106
+ else:
107
+ # Remove the used point? Not necessarily, but for efficiency we could.
108
+ # Actually, the used point is now inside an item, so it will fail can_fit for future items
109
+ # But to keep list small, we can remove it.
110
+ if (item.x, item.y, item.z) in self.points:
111
+ self.points.remove((item.x, item.y, item.z))
112
+
113
+ def _add_new_points(self, item):
114
+ # Add 3 new candidate points relative to the item
115
+ # 1. Top of item
116
+ p1 = (item.x, item.y + item.h, item.z)
117
+ # 2. Right of item
118
+ p2 = (item.x + item.w, item.y, item.z)
119
+ # 3. Front of item
120
+ p3 = (item.x, item.y, item.z + item.d)
121
+
122
+ for p in [p1, p2, p3]:
123
+ # Simple bound check optimization
124
+ if p[0] < self.w and p[1] < self.h and p[2] < self.d:
125
+ if p not in self.points:
126
+ self.points.append(p)
127
+
128
+ # --- Routes ---
129
+
130
+ @app.route('/')
131
+ def index():
132
+ return render_template('index.html')
133
+
134
+ @app.route('/api/import-excel', methods=['POST'])
135
+ def import_excel():
136
+ if 'file' not in request.files:
137
+ return jsonify({'status': 'error', 'message': 'No file part'}), 400
138
+ file = request.files['file']
139
+ if file.filename == '':
140
+ return jsonify({'status': 'error', 'message': 'No selected file'}), 400
141
+
142
+ if file and (file.filename.endswith('.xlsx') or file.filename.endswith('.xls')):
143
+ try:
144
+ df = pd.read_excel(file)
145
+ # Expected columns: name, width, height, depth, quantity, color, weight
146
+ # Map columns vaguely
147
+ items = []
148
+ for _, row in df.iterrows():
149
+ # Normalize keys
150
+ row_keys = {k.lower(): v for k, v in row.to_dict().items()}
151
+
152
+ # Helper to find key
153
+ def get_val(keys_list, default=None):
154
+ for k in keys_list:
155
+ for rk in row_keys:
156
+ if k in rk:
157
+ return row_keys[rk]
158
+ return default
159
+
160
+ items.append({
161
+ 'name': str(get_val(['name', '名称', '名字'], 'Item')),
162
+ 'width': float(get_val(['width', '宽', 'length', '长'], 10)),
163
+ 'height': float(get_val(['height', '高'], 10)),
164
+ 'depth': float(get_val(['depth', '深', 'width2'], 10)),
165
+ 'quantity': int(get_val(['quantity', 'qty', '数量', 'count'], 1)),
166
+ 'color': str(get_val(['color', '颜色'], '#888888')),
167
+ 'weight': float(get_val(['weight', '重'], 1))
168
+ })
169
+ return jsonify({'status': 'success', 'items': items})
170
+ except Exception as e:
171
+ return jsonify({'status': 'error', 'message': str(e)}), 500
172
+ return jsonify({'status': 'error', 'message': 'Invalid file type'}), 400
173
+
174
+ @app.route('/api/calculate', methods=['POST'])
175
+ def calculate():
176
+ data = request.json
177
+ container = data.get('container', {})
178
+ items_data = data.get('items', [])
179
+
180
+ # Create Bin
181
+ # Assuming Y is vertical height.
182
+ bin_obj = Bin(
183
+ w=container.get('width', 200),
184
+ h=container.get('height', 200),
185
+ d=container.get('depth', 400),
186
+ max_weight=container.get('max_weight', 10000)
187
+ )
188
+
189
+ # Create Items
190
+ items = []
191
+ for i_data in items_data:
192
+ qty = int(i_data.get('quantity', 1))
193
+ for _ in range(qty):
194
+ items.append(Item(
195
+ id=str(uuid.uuid4())[:8],
196
+ name=i_data.get('name', 'Item'),
197
+ w=i_data.get('width'),
198
+ h=i_data.get('height'),
199
+ d=i_data.get('depth'),
200
+ color=i_data.get('color', '#888888'),
201
+ weight=i_data.get('weight', 0)
202
+ ))
203
+
204
+ start_time = time.time()
205
+ bin_obj.pack(items)
206
+ end_time = time.time()
207
+
208
+ # Calculate stats
209
+ total_volume = bin_obj.w * bin_obj.h * bin_obj.d
210
+ used_volume = sum(i.volume for i in bin_obj.items)
211
+ volume_util = (used_volume / total_volume) * 100 if total_volume > 0 else 0
212
+
213
+ total_weight = sum(i.weight for i in bin_obj.items)
214
+
215
+ return jsonify({
216
+ 'status': 'success',
217
+ 'time_taken': f"{end_time - start_time:.4f}s",
218
+ 'container': {
219
+ 'w': bin_obj.w, 'h': bin_obj.h, 'd': bin_obj.d
220
+ },
221
+ 'items_packed': [i.to_dict() for i in bin_obj.items],
222
+ 'items_unpacked': [i.to_dict() for i in bin_obj.unpacked_items],
223
+ 'stats': {
224
+ 'volume_utilization': round(volume_util, 2),
225
+ 'packed_count': len(bin_obj.items),
226
+ 'unpacked_count': len(bin_obj.unpacked_items),
227
+ 'total_weight': total_weight
228
+ }
229
+ })
230
+
231
+ if __name__ == '__main__':
232
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ flask
2
+ pandas
3
+ openpyxl
templates/index.html ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>智能装箱大师 | Smart Load Packer</title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
11
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
12
+ <script>
13
+ tailwind.config = {
14
+ darkMode: 'class',
15
+ theme: {
16
+ extend: {
17
+ colors: {
18
+ primary: '#3b82f6',
19
+ secondary: '#10b981',
20
+ dark: '#1f2937'
21
+ }
22
+ }
23
+ }
24
+ }
25
+ </script>
26
+ <style>
27
+ body { font-family: 'Inter', sans-serif; }
28
+ #canvas-container { width: 100%; height: 500px; background: #f0f0f0; border-radius: 8px; overflow: hidden; }
29
+ .dark #canvas-container { background: #111827; }
30
+ /* Custom Scrollbar */
31
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
32
+ ::-webkit-scrollbar-track { background: transparent; }
33
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
34
+ ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
35
+ .dark ::-webkit-scrollbar-thumb { background: #374151; }
36
+ </style>
37
+ </head>
38
+ <body class="bg-gray-50 text-gray-800 dark:bg-gray-900 dark:text-gray-100 transition-colors duration-200">
39
+ <div id="app" class="min-h-screen flex flex-col">
40
+ <!-- Header -->
41
+ <header class="bg-white dark:bg-gray-800 shadow-sm z-10">
42
+ <div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
43
+ <div class="flex items-center space-x-3">
44
+ <i class="fas fa-cubes text-primary text-2xl"></i>
45
+ <h1 class="text-xl font-bold tracking-tight">智能装箱大师 <span class="text-xs font-normal text-gray-500 dark:text-gray-400">Smart Load Packer</span></h1>
46
+ </div>
47
+ <div class="flex items-center space-x-4">
48
+ <button @click="toggleDark" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition">
49
+ <i :class="isDark ? 'fas fa-sun text-yellow-400' : 'fas fa-moon text-gray-600'"></i>
50
+ </button>
51
+ <a href="https://huggingface.co/spaces" target="_blank" class="text-sm text-gray-500 hover:text-primary">
52
+ <i class="fas fa-rocket mr-1"></i> Deploy to HF
53
+ </a>
54
+ </div>
55
+ </div>
56
+ </header>
57
+
58
+ <!-- Main Content -->
59
+ <main class="flex-1 max-w-7xl w-full mx-auto px-4 py-6 grid grid-cols-1 lg:grid-cols-12 gap-6">
60
+
61
+ <!-- Left Panel: Configuration -->
62
+ <div class="lg:col-span-4 space-y-6 overflow-y-auto max-h-[calc(100vh-100px)] pr-2">
63
+ <!-- Container Config -->
64
+ <div class="bg-white dark:bg-gray-800 p-5 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
65
+ <h2 class="text-lg font-semibold mb-4 flex items-center">
66
+ <i class="fas fa-truck-loading mr-2 text-primary"></i> 容器设置 (Container)
67
+ </h2>
68
+ <div class="grid grid-cols-3 gap-3 mb-3">
69
+ <div>
70
+ <label class="block text-xs font-medium text-gray-500 mb-1">宽 (Width)</label>
71
+ <input v-model.number="container.width" type="number" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-primary outline-none">
72
+ </div>
73
+ <div>
74
+ <label class="block text-xs font-medium text-gray-500 mb-1">高 (Height)</label>
75
+ <input v-model.number="container.height" type="number" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-primary outline-none">
76
+ </div>
77
+ <div>
78
+ <label class="block text-xs font-medium text-gray-500 mb-1">深 (Depth)</label>
79
+ <input v-model.number="container.depth" type="number" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-primary outline-none">
80
+ </div>
81
+ </div>
82
+ <div>
83
+ <label class="block text-xs font-medium text-gray-500 mb-1">最大载重 (Max Weight)</label>
84
+ <input v-model.number="container.max_weight" type="number" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-primary outline-none">
85
+ </div>
86
+ </div>
87
+
88
+ <!-- Items Config -->
89
+ <div class="bg-white dark:bg-gray-800 p-5 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
90
+ <div class="flex justify-between items-center mb-4">
91
+ <h2 class="text-lg font-semibold flex items-center">
92
+ <i class="fas fa-box-open mr-2 text-secondary"></i> 货物列表 (Cargo)
93
+ </h2>
94
+ <button @click="addItem" class="text-xs bg-secondary hover:bg-green-600 text-white px-3 py-1.5 rounded transition">
95
+ <i class="fas fa-plus mr-1"></i> 添加货物
96
+ </button>
97
+ </div>
98
+
99
+ <div class="space-y-3">
100
+ <div v-for="(item, index) in items" :key="index" class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 relative group">
101
+ <button @click="removeItem(index)" class="absolute top-2 right-2 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition">
102
+ <i class="fas fa-times"></i>
103
+ </button>
104
+ <div class="grid grid-cols-1 gap-2 mb-2">
105
+ <input v-model="item.name" type="text" placeholder="货物名称" class="w-full bg-transparent border-b border-gray-300 dark:border-gray-600 text-sm focus:border-primary outline-none pb-1">
106
+ </div>
107
+ <div class="grid grid-cols-4 gap-2 mb-2">
108
+ <div><label class="text-[10px] text-gray-400">W</label><input v-model.number="item.width" type="number" class="w-full bg-white dark:bg-gray-800 rounded px-1 py-1 text-xs border border-gray-200 dark:border-gray-600"></div>
109
+ <div><label class="text-[10px] text-gray-400">H</label><input v-model.number="item.height" type="number" class="w-full bg-white dark:bg-gray-800 rounded px-1 py-1 text-xs border border-gray-200 dark:border-gray-600"></div>
110
+ <div><label class="text-[10px] text-gray-400">D</label><input v-model.number="item.depth" type="number" class="w-full bg-white dark:bg-gray-800 rounded px-1 py-1 text-xs border border-gray-200 dark:border-gray-600"></div>
111
+ <div><label class="text-[10px] text-gray-400">Qty</label><input v-model.number="item.quantity" type="number" class="w-full bg-white dark:bg-gray-800 rounded px-1 py-1 text-xs border border-gray-200 dark:border-gray-600 font-bold text-primary"></div>
112
+ </div>
113
+ <div class="flex items-center gap-2">
114
+ <input v-model="item.color" type="color" class="h-6 w-6 rounded cursor-pointer border-none bg-transparent">
115
+ <input v-model.number="item.weight" type="number" placeholder="Weight" class="flex-1 bg-white dark:bg-gray-800 rounded px-2 py-1 text-xs border border-gray-200 dark:border-gray-600">
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ <button @click="calculate" :disabled="loading" class="w-full bg-primary hover:bg-blue-600 text-white py-3 rounded-xl font-bold shadow-lg shadow-blue-500/30 transition transform active:scale-95 flex justify-center items-center">
122
+ <span v-if="loading"><i class="fas fa-spinner fa-spin mr-2"></i> 计算中...</span>
123
+ <span v-else><i class="fas fa-calculator mr-2"></i> 开始装箱计算 (Calculate)</span>
124
+ </button>
125
+ </div>
126
+
127
+ <!-- Right Panel: Visualization & Stats -->
128
+ <div class="lg:col-span-8 flex flex-col gap-6">
129
+ <!-- 3D View -->
130
+ <div class="bg-white dark:bg-gray-800 p-1 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 relative">
131
+ <div id="canvas-container"></div>
132
+ <div class="absolute top-4 left-4 bg-black/50 text-white px-3 py-1 rounded text-xs backdrop-blur">
133
+ <i class="fas fa-mouse mr-1"></i> 左键旋转 / 右键平移 / 滚轮缩放
134
+ </div>
135
+ </div>
136
+
137
+ <!-- Stats Grid -->
138
+ <div v-if="result" class="grid grid-cols-2 md:grid-cols-4 gap-4">
139
+ <div class="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm">
140
+ <div class="text-xs text-gray-500 mb-1">空间利用率 (Volume)</div>
141
+ <div class="text-2xl font-bold text-primary">${ result.stats.volume_utilization }%</div>
142
+ <div class="w-full bg-gray-200 rounded-full h-1.5 mt-2">
143
+ <div class="bg-primary h-1.5 rounded-full" :style="{ width: result.stats.volume_utilization + '%' }"></div>
144
+ </div>
145
+ </div>
146
+ <div class="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm">
147
+ <div class="text-xs text-gray-500 mb-1">已装载数量 (Packed)</div>
148
+ <div class="text-2xl font-bold text-secondary">${ result.stats.packed_count } <span class="text-sm text-gray-400">/ ${ totalItemsCount }</span></div>
149
+ </div>
150
+ <div class="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm">
151
+ <div class="text-xs text-gray-500 mb-1">总重量 (Weight)</div>
152
+ <div class="text-2xl font-bold text-orange-500">${ result.stats.total_weight } <span class="text-sm text-gray-400">kg</span></div>
153
+ </div>
154
+ <div class="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm">
155
+ <div class="text-xs text-gray-500 mb-1">未装载 (Failed)</div>
156
+ <div class="text-2xl font-bold text-red-500">${ result.stats.unpacked_count }</div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Manifest Table -->
161
+ <div v-if="result && result.items_packed.length > 0" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden">
162
+ <div class="px-5 py-3 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
163
+ <h3 class="font-semibold text-sm">装箱清单 (Manifest)</h3>
164
+ </div>
165
+ <div class="overflow-x-auto">
166
+ <table class="w-full text-sm text-left">
167
+ <thead class="text-xs text-gray-500 uppercase bg-gray-50 dark:bg-gray-700/50">
168
+ <tr>
169
+ <th class="px-4 py-3">ID</th>
170
+ <th class="px-4 py-3">Name</th>
171
+ <th class="px-4 py-3">Size (WxHxD)</th>
172
+ <th class="px-4 py-3">Position (x,y,z)</th>
173
+ </tr>
174
+ </thead>
175
+ <tbody>
176
+ <tr v-for="item in result.items_packed" :key="item.id" class="border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
177
+ <td class="px-4 py-2 font-mono text-xs text-gray-400">${ item.id }</td>
178
+ <td class="px-4 py-2 flex items-center">
179
+ <span class="w-3 h-3 rounded-full mr-2" :style="{ backgroundColor: item.color }"></span>
180
+ ${ item.name }
181
+ </td>
182
+ <td class="px-4 py-2 text-gray-500">${ item.w } x ${ item.h } x ${ item.d }</td>
183
+ <td class="px-4 py-2 font-mono text-xs text-primary">${ item.x }, ${ item.y }, ${ item.z }</td>
184
+ </tr>
185
+ </tbody>
186
+ </table>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </main>
191
+ </div>
192
+
193
+ <script>
194
+ const { createApp, ref, computed, onMounted, watch } = Vue;
195
+
196
+ createApp({
197
+ delimiters: ['${', '}'],
198
+ setup() {
199
+ const isDark = ref(false);
200
+ const loading = ref(false);
201
+ const result = ref(null);
202
+
203
+ // Initial Data
204
+ const container = ref({
205
+ width: 500,
206
+ height: 300,
207
+ depth: 800,
208
+ max_weight: 5000
209
+ });
210
+
211
+ const items = ref([
212
+ { name: 'Standard Box A', width: 100, height: 100, depth: 100, quantity: 12, color: '#3b82f6', weight: 10 },
213
+ { name: 'Long Box B', width: 200, height: 50, depth: 100, quantity: 8, color: '#10b981', weight: 15 },
214
+ { name: 'Large Crate C', width: 150, height: 150, depth: 150, quantity: 4, color: '#f59e0b', weight: 30 },
215
+ ]);
216
+
217
+ // Three.js variables
218
+ let scene, camera, renderer, controls, containerMesh;
219
+ let itemMeshes = [];
220
+
221
+ const toggleDark = () => {
222
+ isDark.value = !isDark.value;
223
+ if (isDark.value) {
224
+ document.documentElement.classList.add('dark');
225
+ } else {
226
+ document.documentElement.classList.remove('dark');
227
+ }
228
+ initThree(); // Re-init to update background colors
229
+ if(result.value) visualize(result.value);
230
+ };
231
+
232
+ const addItem = () => {
233
+ items.value.push({
234
+ name: 'New Item', width: 50, height: 50, depth: 50, quantity: 1, color: '#' + Math.floor(Math.random()*16777215).toString(16), weight: 5
235
+ });
236
+ };
237
+
238
+ const removeItem = (index) => {
239
+ items.value.splice(index, 1);
240
+ };
241
+
242
+ const importExcel = async (event) => {
243
+ const file = event.target.files[0];
244
+ if (!file) return;
245
+
246
+ const formData = new FormData();
247
+ formData.append('file', file);
248
+
249
+ loading.value = true;
250
+ try {
251
+ const response = await fetch('/api/import-excel', {
252
+ method: 'POST',
253
+ body: formData
254
+ });
255
+ const data = await response.json();
256
+ if (data.status === 'success') {
257
+ // Merge or replace? Let's append.
258
+ items.value = [...items.value, ...data.items];
259
+ alert(`Successfully imported ${data.items.length} items.`);
260
+ } else {
261
+ alert('Import failed: ' + data.message);
262
+ }
263
+ } catch (e) {
264
+ alert('Error uploading file: ' + e.message);
265
+ } finally {
266
+ loading.value = false;
267
+ event.target.value = ''; // Reset input
268
+ }
269
+ };
270
+
271
+ const totalItemsCount = computed(() => {
272
+ return items.value.reduce((acc, item) => acc + item.quantity, 0);
273
+ });
274
+
275
+ const calculate = async () => {
276
+ loading.value = true;
277
+ try {
278
+ const response = await fetch('/api/calculate', {
279
+ method: 'POST',
280
+ headers: { 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({
282
+ container: container.value,
283
+ items: items.value
284
+ })
285
+ });
286
+ const data = await response.json();
287
+ result.value = data;
288
+ visualize(data);
289
+ } catch (e) {
290
+ alert('Calculation failed: ' + e.message);
291
+ } finally {
292
+ loading.value = false;
293
+ }
294
+ };
295
+
296
+ // Three.js Visualization Logic
297
+ const initThree = () => {
298
+ const canvasContainer = document.getElementById('canvas-container');
299
+ canvasContainer.innerHTML = ''; // Clear existing
300
+
301
+ const width = canvasContainer.clientWidth;
302
+ const height = canvasContainer.clientHeight;
303
+
304
+ scene = new THREE.Scene();
305
+ scene.background = new THREE.Color(isDark.value ? 0x111827 : 0xf0f0f0);
306
+
307
+ // Camera setup
308
+ camera = new THREE.PerspectiveCamera(45, width / height, 1, 10000);
309
+ camera.position.set(1000, 1000, 1000);
310
+ camera.lookAt(0, 0, 0);
311
+
312
+ renderer = new THREE.WebGLRenderer({ antialias: true });
313
+ renderer.setSize(width, height);
314
+ canvasContainer.appendChild(renderer.domElement);
315
+
316
+ // Controls
317
+ controls = new THREE.OrbitControls(camera, renderer.domElement);
318
+ controls.enableDamping = true;
319
+
320
+ // Lights
321
+ const ambientLight = new THREE.AmbientLight(0x404040, 1.5);
322
+ scene.add(ambientLight);
323
+
324
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1);
325
+ dirLight.position.set(500, 1000, 500);
326
+ scene.add(dirLight);
327
+
328
+ // Initial Grid/Ground
329
+ // const gridHelper = new THREE.GridHelper(2000, 20);
330
+ // scene.add(gridHelper);
331
+
332
+ animate();
333
+ };
334
+
335
+ const visualize = (data) => {
336
+ // Clear previous items
337
+ while(scene.children.length > 0){
338
+ scene.remove(scene.children[0]);
339
+ }
340
+
341
+ // Re-add lights
342
+ const ambientLight = new THREE.AmbientLight(0x404040, 1.5);
343
+ scene.add(ambientLight);
344
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1);
345
+ dirLight.position.set(data.container.w, data.container.h * 2, data.container.d);
346
+ scene.add(dirLight);
347
+
348
+ // Draw Container Wireframe
349
+ // Note: Three.js coordinates are usually centered.
350
+ // Our packing coordinates are from (0,0,0).
351
+ // So we need to offset everything so (0,0,0) is at a corner or center the group.
352
+
353
+ const cw = data.container.w;
354
+ const ch = data.container.h;
355
+ const cd = data.container.d;
356
+
357
+ const geometry = new THREE.BoxGeometry(cw, ch, cd);
358
+ const edges = new THREE.EdgesGeometry(geometry);
359
+ const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: isDark.value ? 0xffffff : 0x000000 }));
360
+
361
+ // Center of container is at (cw/2, ch/2, cd/2) in our packing logic
362
+ // In Three.js BoxGeometry is centered at (0,0,0)
363
+ // So we place the wireframe at (0,0,0) and move our items relative to it?
364
+ // No, easier to place wireframe at (0,0,0) and represent that as center.
365
+ // Then item at (x,y,z) with size (w,h,d) needs to be mapped to Three.js world.
366
+
367
+ // Let's treat (0,0,0) of Three.js world as the Center of the Container bottom face?
368
+ // Or just Center of Container Volume.
369
+
370
+ line.position.set(0, ch/2, 0); // Lift up so bottom is at y=0 if we assume floor is 0?
371
+ // Actually, let's keep it simple: Center everything around (0,0,0)
372
+ // Container center: (0,0,0)
373
+ // Container bottom-left-back: (-cw/2, -ch/2, -cd/2)
374
+
375
+ scene.add(line);
376
+
377
+ // Add Axes
378
+ const axesHelper = new THREE.AxesHelper(Math.max(cw,ch,cd)/2 + 100);
379
+ scene.add(axesHelper);
380
+
381
+ // Draw Packed Items
382
+ const offset = { x: -cw/2, y: -ch/2, z: -cd/2 };
383
+
384
+ data.items_packed.forEach(item => {
385
+ const geometry = new THREE.BoxGeometry(item.w, item.h, item.d);
386
+ const material = new THREE.MeshLambertMaterial({
387
+ color: item.color,
388
+ opacity: 0.9,
389
+ transparent: true
390
+ });
391
+ const mesh = new THREE.Mesh(geometry, material);
392
+
393
+ // Edges for item
394
+ const itemEdges = new THREE.EdgesGeometry(geometry);
395
+ const itemLine = new THREE.LineSegments(itemEdges, new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 }));
396
+ mesh.add(itemLine);
397
+
398
+ // Position
399
+ // Item x,y,z is bottom-left-back corner in packing logic.
400
+ // Three.js mesh position is center of mesh.
401
+ mesh.position.set(
402
+ offset.x + item.x + item.w/2,
403
+ offset.y + item.y + item.h/2,
404
+ offset.z + item.z + item.d/2
405
+ );
406
+
407
+ scene.add(mesh);
408
+ });
409
+
410
+ // Adjust Camera
411
+ const maxDim = Math.max(cw, ch, cd);
412
+ camera.position.set(maxDim * 1.5, maxDim * 1.2, maxDim * 1.5);
413
+ controls.target.set(0, 0, 0);
414
+ controls.update();
415
+ };
416
+
417
+ const animate = () => {
418
+ requestAnimationFrame(animate);
419
+ controls.update();
420
+ renderer.render(scene, camera);
421
+ };
422
+
423
+ onMounted(() => {
424
+ // Load from LocalStorage
425
+ const savedContainer = localStorage.getItem('slp_container');
426
+ if (savedContainer) container.value = JSON.parse(savedContainer);
427
+
428
+ const savedItems = localStorage.getItem('slp_items');
429
+ if (savedItems) items.value = JSON.parse(savedItems);
430
+
431
+ initThree();
432
+ window.addEventListener('resize', initThree);
433
+ });
434
+
435
+ // Persistence
436
+ watch(container, (newVal) => {
437
+ localStorage.setItem('slp_container', JSON.stringify(newVal));
438
+ }, { deep: true });
439
+
440
+ watch(items, (newVal) => {
441
+ localStorage.setItem('slp_items', JSON.stringify(newVal));
442
+ }, { deep: true });
443
+
444
+ return {
445
+ isDark, toggleDark,
446
+ container, items,
447
+ loading, result,
448
+ addItem, removeItem,
449
+ calculate, totalItemsCount
450
+ };
451
+ }
452
+ }).mount('#app');
453
+ </script>
454
+ </body>
455
+ </html>