Trae Assistant commited on
Commit
dda07d1
·
0 Parent(s):

feat: enhance assembly line tool with import/export and robust error handling

Browse files
Files changed (6) hide show
  1. .gitignore +8 -0
  2. Dockerfile +17 -0
  3. README.md +55 -0
  4. app.py +180 -0
  5. requirements.txt +2 -0
  6. templates/index.html +606 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .DS_Store
5
+ .env
6
+ venv/
7
+ .idea/
8
+ .vscode/
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create a non-root user for security (good practice for HF Spaces)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV PATH="/home/user/.local/bin:$PATH"
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Assembly Line Flow Pro
3
+ emoji: 🏭
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ short_description: 制造业产线平衡与瓶颈分析工具 (Manufacturing Assembly Line Balancing)
10
+ ---
11
+
12
+ # Assembly Line Flow Pro (产线平衡优化大师)
13
+
14
+ **Assembly Line Flow Pro** 是一个专为制造业工程师和生产管理人员设计的**产线平衡与瓶颈分析工具**。通过直观的拖拽交互和实时数据分析,帮助用户优化装配线工序分配,提升生产效率 (Line Efficiency) 并消除瓶颈 (Bottleneck)。
15
+
16
+ ## 核心功能 (Key Features)
17
+
18
+ * **智能自动平衡 (Auto-Balance)**: 基于目标节拍时间 (Takt Time) 的贪婪算法,自动重新分配工序,实现产线平衡。
19
+ * **可视化山积图 (Yamazumi Chart)**: 实时展示各工位负荷与 Takt Time 的对比,红/绿颜色直观预警过载工位。
20
+ * **动态交互设计**: 支持拖拽 (Drag & Drop) 调整工序,实时计算线体平衡率、平滑指数等关键指标。
21
+ * **多维度生产配置**: 自定义目标产量、工作时长,系统自动计算理论产能和节拍要求。
22
+
23
+ ## 商业应用场景 (Use Cases)
24
+
25
+ 1. **新产线规划**: 在物理产线搭建前,模拟工序分配,预测所需工位数量。
26
+ 2. **持续改进 (Kaizen)**: 识别现有产线的瓶颈工位,进行动作拆解或重新分配。
27
+ 3. **产能评估**: 根据订单需求变化,快速计算当前产线配置是否满足交付目标。
28
+
29
+ ## 技术栈 (Tech Stack)
30
+
31
+ * **Frontend**: Vue 3 (CDN), Element Plus (UI), ECharts (Visualization), HTML5 Drag & Drop
32
+ * **Backend**: Flask (Python)
33
+ * **Deployment**: Docker
34
+
35
+ ## 快速开始 (Quick Start)
36
+
37
+ ### Docker 部署
38
+
39
+ ```bash
40
+ docker build -t assembly-line-flow-pro .
41
+ docker run -p 7860:7860 assembly-line-flow-pro
42
+ ```
43
+
44
+ 访问: `http://localhost:7860`
45
+
46
+ ### 本地运行
47
+
48
+ ```bash
49
+ pip install -r requirements.txt
50
+ python app.py
51
+ ```
52
+
53
+ ## 许可证 (License)
54
+
55
+ MIT License
app.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import math
4
+ from flask import Flask, render_template, request, jsonify
5
+
6
+ app = Flask(__name__)
7
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB Max Limit
8
+
9
+ @app.errorhandler(404)
10
+ def page_not_found(e):
11
+ return render_template('index.html'), 404
12
+
13
+ @app.errorhandler(500)
14
+ def internal_server_error(e):
15
+ return jsonify({"error": "Internal Server Error", "details": str(e)}), 500
16
+
17
+ @app.route('/api/upload', methods=['POST'])
18
+ def upload_file():
19
+ try:
20
+ if 'file' not in request.files:
21
+ return jsonify({"error": "No file part"}), 400
22
+ file = request.files['file']
23
+ if file.filename == '':
24
+ return jsonify({"error": "No selected file"}), 400
25
+ if file:
26
+ try:
27
+ data = json.load(file)
28
+ return jsonify({"message": "File uploaded successfully", "data": data})
29
+ except json.JSONDecodeError:
30
+ return jsonify({"error": "Invalid JSON file"}), 400
31
+ except Exception as e:
32
+ return jsonify({"error": str(e)}), 500
33
+
34
+ # Default Data (Enriched for User Experience)
35
+ DEFAULT_TASKS = [
36
+ {"id": "t1", "name": "安装底壳 (Install Chassis)", "duration": 15},
37
+ {"id": "t2", "name": "安装主板 (Mount PCB)", "duration": 25},
38
+ {"id": "t3", "name": "连接电源线 (Connect Power)", "duration": 10},
39
+ {"id": "t4", "name": "安装风扇 (Install Fan)", "duration": 12},
40
+ {"id": "t5", "name": "固定硬盘 (Secure HDD)", "duration": 18},
41
+ {"id": "t6", "name": "安装显卡 (Install GPU)", "duration": 20},
42
+ {"id": "t7", "name": "理线 (Cable Mgmt)", "duration": 30},
43
+ {"id": "t8", "name": "合盖 (Close Case)", "duration": 10},
44
+ {"id": "t9", "name": "贴标签 (Labeling)", "duration": 5},
45
+ {"id": "t10", "name": "最终测试 (Final Test)", "duration": 40}
46
+ ]
47
+
48
+ DEFAULT_CONFIG = {
49
+ "work_hours": 8,
50
+ "target_output": 100 # Units per day
51
+ }
52
+
53
+ @app.route('/')
54
+ def index():
55
+ return render_template('index.html')
56
+
57
+ @app.route('/api/calculate', methods=['POST'])
58
+ def calculate_metrics():
59
+ """
60
+ Calculate Line Efficiency, Smoothness Index, and Takt Time.
61
+ Input: { "stations": [[task1, task2], [task3]], "config": {...} }
62
+ """
63
+ data = request.json
64
+ stations = data.get('stations', [])
65
+ config = data.get('config', DEFAULT_CONFIG)
66
+
67
+ # 1. Calculate Takt Time (Seconds)
68
+ # Takt Time = Available Time / Demand
69
+ available_seconds = config.get('work_hours', 8) * 3600
70
+ demand = config.get('target_output', 100)
71
+ if demand <= 0: demand = 1
72
+ takt_time = available_seconds / demand
73
+
74
+ # 2. Calculate Station Times
75
+ station_times = []
76
+ total_work_content = 0
77
+
78
+ for station in stations:
79
+ s_time = sum(t.get('duration', 0) for t in station)
80
+ station_times.append(s_time)
81
+ total_work_content += s_time
82
+
83
+ if not station_times:
84
+ return jsonify({"error": "No stations defined"})
85
+
86
+ # 3. Calculate Bottleneck (Max Station Time)
87
+ bottleneck_time = max(station_times)
88
+ bottleneck_index = station_times.index(bottleneck_time)
89
+
90
+ # 4. Calculate Efficiency
91
+ # Efficiency = Total Work Content / (Num Stations * Bottleneck Time)
92
+ num_stations = len(stations)
93
+ efficiency = 0
94
+ if num_stations > 0 and bottleneck_time > 0:
95
+ efficiency = (total_work_content / (num_stations * bottleneck_time)) * 100
96
+
97
+ # 5. Calculate Smoothness Index (SI)
98
+ # SI = Sqrt(Sum( (Max - Si)^2 ))
99
+ si_sum = sum((bottleneck_time - st)**2 for st in station_times)
100
+ smoothness_index = math.sqrt(si_sum)
101
+
102
+ # 6. Capacity Calculation
103
+ # Hourly Capacity = 3600 / Bottleneck Time
104
+ hourly_capacity = 0
105
+ if bottleneck_time > 0:
106
+ hourly_capacity = 3600 / bottleneck_time
107
+
108
+ daily_capacity = hourly_capacity * config.get('work_hours', 8)
109
+
110
+ return jsonify({
111
+ "metrics": {
112
+ "takt_time": round(takt_time, 2),
113
+ "bottleneck_time": round(bottleneck_time, 2),
114
+ "bottleneck_station": bottleneck_index + 1,
115
+ "line_efficiency": round(efficiency, 2),
116
+ "smoothness_index": round(smoothness_index, 2),
117
+ "hourly_capacity": round(hourly_capacity, 1),
118
+ "daily_capacity": round(daily_capacity, 1),
119
+ "total_work_content": total_work_content
120
+ },
121
+ "station_times": station_times
122
+ })
123
+
124
+ @app.route('/api/auto_balance', methods=['POST'])
125
+ def auto_balance():
126
+ """
127
+ Simple Greedy Algorithm to re-balance line.
128
+ Try to fill stations up to Takt Time (or slightly above if unavoidable).
129
+ """
130
+ data = request.json
131
+ all_tasks = data.get('tasks', [])
132
+ config = data.get('config', DEFAULT_CONFIG)
133
+
134
+ # Calculate Takt Time Target
135
+ available_seconds = config.get('work_hours', 8) * 3600
136
+ demand = config.get('target_output', 100)
137
+ takt_time = available_seconds / demand if demand > 0 else 60
138
+
139
+ # Heuristic: Sort tasks?
140
+ # In real assembly, order matters (precedence).
141
+ # Here we assume the Input List order IS the precedence constraint order (roughly).
142
+ # We will just cut the list into chunks.
143
+
144
+ new_stations = []
145
+ current_station = []
146
+ current_time = 0
147
+
148
+ for task in all_tasks:
149
+ t_dur = task.get('duration', 0)
150
+
151
+ # If adding this task exceeds Takt Time significantly, start new station?
152
+ # Let's try to fit as much as possible <= Takt Time.
153
+ # If a single task > Takt Time, it must be in a station alone (and will be bottleneck).
154
+
155
+ if current_time + t_dur <= takt_time:
156
+ current_station.append(task)
157
+ current_time += t_dur
158
+ else:
159
+ # If current station is not empty, close it
160
+ if current_station:
161
+ new_stations.append(current_station)
162
+ current_station = [task]
163
+ current_time = t_dur
164
+ else:
165
+ # Task itself is larger than Takt Time
166
+ new_stations.append([task])
167
+ current_time = 0 # Reset for next
168
+ current_station = []
169
+
170
+ if current_station:
171
+ new_stations.append(current_station)
172
+
173
+ return jsonify({
174
+ "stations": new_stations,
175
+ "message": "Auto-balance completed based on Takt Time constraint."
176
+ })
177
+
178
+ if __name__ == '__main__':
179
+ port = int(os.environ.get('PORT', 7860))
180
+ app.run(host='0.0.0.0', port=port, debug=True)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask==3.0.0
2
+ gunicorn==21.2.0
templates/index.html ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Assembly Line Flow Pro - 产线平衡优化大师</title>
7
+
8
+ <!-- CSS Dependencies -->
9
+ <link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
10
+ <style>
11
+ :root {
12
+ --el-color-primary: #409EFF;
13
+ --el-color-success: #67C23A;
14
+ --el-color-danger: #F56C6C;
15
+ }
16
+ body {
17
+ font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
18
+ background-color: #f5f7fa;
19
+ margin: 0;
20
+ padding: 0;
21
+ }
22
+ .container {
23
+ max-width: 1400px;
24
+ margin: 0 auto;
25
+ padding: 20px;
26
+ }
27
+ .header {
28
+ background: #fff;
29
+ padding: 20px;
30
+ border-bottom: 1px solid #e6e6e6;
31
+ margin-bottom: 20px;
32
+ display: flex;
33
+ justify-content: space-between;
34
+ align-items: center;
35
+ }
36
+ .header h1 {
37
+ margin: 0;
38
+ font-size: 24px;
39
+ color: #303133;
40
+ }
41
+ .station-card {
42
+ background: #fff;
43
+ border-radius: 4px;
44
+ border: 1px solid #dcdfe6;
45
+ margin-bottom: 15px;
46
+ transition: all 0.3s;
47
+ }
48
+ .station-card:hover {
49
+ box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
50
+ }
51
+ .station-header {
52
+ padding: 10px 15px;
53
+ background: #f5f7fa;
54
+ border-bottom: 1px solid #ebeef5;
55
+ display: flex;
56
+ justify-content: space-between;
57
+ align-items: center;
58
+ font-weight: bold;
59
+ }
60
+ .station-body {
61
+ padding: 10px;
62
+ min-height: 50px;
63
+ }
64
+ .task-item {
65
+ background: #ecf5ff;
66
+ border: 1px solid #d9ecff;
67
+ color: #409EFF;
68
+ padding: 8px;
69
+ margin-bottom: 5px;
70
+ border-radius: 4px;
71
+ display: flex;
72
+ justify-content: space-between;
73
+ align-items: center;
74
+ cursor: move;
75
+ }
76
+ .task-item.warning {
77
+ background: #fef0f0;
78
+ border-color: #fde2e2;
79
+ color: #F56C6C;
80
+ }
81
+ .metrics-card {
82
+ background: #fff;
83
+ padding: 20px;
84
+ border-radius: 4px;
85
+ text-align: center;
86
+ }
87
+ .metric-value {
88
+ font-size: 24px;
89
+ font-weight: bold;
90
+ color: #303133;
91
+ margin: 10px 0;
92
+ }
93
+ .metric-label {
94
+ color: #909399;
95
+ font-size: 14px;
96
+ }
97
+ .chart-container {
98
+ height: 400px;
99
+ background: #fff;
100
+ padding: 20px;
101
+ border-radius: 4px;
102
+ margin-top: 20px;
103
+ }
104
+ .takt-line-indicator {
105
+ height: 4px;
106
+ background: #e4e7ed;
107
+ margin-top: 10px;
108
+ position: relative;
109
+ border-radius: 2px;
110
+ }
111
+ .takt-fill {
112
+ height: 100%;
113
+ background: #67C23A;
114
+ border-radius: 2px;
115
+ transition: width 0.3s;
116
+ }
117
+ .takt-fill.overload {
118
+ background: #F56C6C;
119
+ }
120
+ </style>
121
+ </head>
122
+ <body>
123
+ <div id="app">
124
+ <div class="header">
125
+ <div style="display: flex; align-items: center; gap: 10px;">
126
+ <el-icon :size="28" color="#409EFF"><Tools /></el-icon>
127
+ <h1>Assembly Line Flow Pro <span style="font-size: 14px; color: #909399; font-weight: normal;">产线平衡优化大师</span></h1>
128
+ </div>
129
+ <div>
130
+ <input type="file" ref="fileInput" style="display: none" @change="handleFileChange" accept=".json">
131
+ <el-button type="warning" @click="triggerUpload">
132
+ <el-icon><Upload /></el-icon> 导入配置
133
+ </el-button>
134
+ <el-button type="info" @click="exportData">
135
+ <el-icon><Download /></el-icon> 导出配置
136
+ </el-button>
137
+ <el-button type="primary" @click="autoBalance" :loading="loading">
138
+ <el-icon><Magic-Stick /></el-icon> 智能自动平衡
139
+ </el-button>
140
+ <el-button type="success" @click="calculateMetrics">
141
+ <el-icon><Refresh /></el-icon> 刷新计算
142
+ </el-button>
143
+ <el-button @click="resetData">
144
+ <el-icon><Delete /></el-icon> 重置数据
145
+ </el-button>
146
+ </div>
147
+ </div>
148
+
149
+ <div class="container">
150
+ <!-- Configuration Row -->
151
+ <el-row :gutter="20" style="margin-bottom: 20px;">
152
+ <el-col :span="6">
153
+ <el-card shadow="hover">
154
+ <template #header>
155
+ <div class="card-header">
156
+ <span>生产目标设定</span>
157
+ </div>
158
+ </template>
159
+ <el-form label-position="top">
160
+ <el-form-item label="计划工时 (小时/天)">
161
+ <el-input-number v-model="config.work_hours" :min="1" :max="24" @change="calculateMetrics"></el-input-number>
162
+ </el-form-item>
163
+ <el-form-item label="目标产量 (台/天)">
164
+ <el-input-number v-model="config.target_output" :min="1" :step="10" @change="calculateMetrics"></el-input-number>
165
+ </el-form-item>
166
+ <div style="background: #f0f9eb; padding: 10px; border-radius: 4px; color: #67C23A;">
167
+ <strong>节拍时间 (Takt): ${ metrics.takt_time } 秒</strong>
168
+ <div style="font-size: 12px; margin-top: 5px;">即每 ${ metrics.takt_time } 秒需产出一台</div>
169
+ </div>
170
+ </el-form>
171
+ </el-card>
172
+ </el-col>
173
+
174
+ <el-col :span="18">
175
+ <el-row :gutter="20">
176
+ <el-col :span="6">
177
+ <div class="metrics-card">
178
+ <div class="metric-label">线体平衡率 (Line Efficiency)</div>
179
+ <div class="metric-value" :style="{ color: metrics.line_efficiency > 85 ? '#67C23A' : '#E6A23C' }">
180
+ ${ metrics.line_efficiency }%
181
+ </div>
182
+ <el-progress :percentage="metrics.line_efficiency" :status="metrics.line_efficiency > 85 ? 'success' : 'warning'"></el-progress>
183
+ </div>
184
+ </el-col>
185
+ <el-col :span="6">
186
+ <div class="metrics-card">
187
+ <div class="metric-label">平滑指数 (Smoothness)</div>
188
+ <div class="metric-value">${ metrics.smoothness_index }</div>
189
+ <div style="font-size: 12px; color: #909399;">越低越好</div>
190
+ </div>
191
+ </el-col>
192
+ <el-col :span="6">
193
+ <div class="metrics-card">
194
+ <div class="metric-label">瓶颈工位 (Bottleneck)</div>
195
+ <div class="metric-value" style="color: #F56C6C;">ST-${ metrics.bottleneck_station }</div>
196
+ <div style="font-size: 12px;">${ metrics.bottleneck_time } 秒</div>
197
+ </div>
198
+ </el-col>
199
+ <el-col :span="6">
200
+ <div class="metrics-card">
201
+ <div class="metric-label">日产能上限 (Max Capacity)</div>
202
+ <div class="metric-value">${ metrics.daily_capacity }</div>
203
+ <div style="font-size: 12px; color: #909399;">理论最大值</div>
204
+ </div>
205
+ </el-col>
206
+ </el-row>
207
+
208
+ <!-- Chart -->
209
+ <div id="balanceChart" class="chart-container" style="height: 220px; margin-top: 15px;"></div>
210
+ </el-col>
211
+ </el-row>
212
+
213
+ <!-- Main Workspace -->
214
+ <el-row :gutter="20">
215
+ <!-- Task Pool / Editor -->
216
+ <el-col :span="6">
217
+ <el-card>
218
+ <template #header>
219
+ <div style="display: flex; justify-content: space-between; align-items: center;">
220
+ <span>工序库 (Task Pool)</span>
221
+ <el-button size="small" type="primary" @click="openTaskDialog">新增</el-button>
222
+ </div>
223
+ </template>
224
+ <div style="max-height: 600px; overflow-y: auto;">
225
+ <div v-for="task in taskPool" :key="task.id" class="task-item" draggable="true" @dragstart="dragStart($event, task, -1)">
226
+ <div>
227
+ <div style="font-weight: bold;">${ task.name }</div>
228
+ <div style="font-size: 12px;">${ task.duration }s</div>
229
+ </div>
230
+ <div>
231
+ <el-tag size="small">${ task.id }</el-tag>
232
+ </div>
233
+ </div>
234
+ <div v-if="taskPool.length === 0" style="text-align: center; color: #909399; padding: 20px;">
235
+ 全部工序已分配
236
+ </div>
237
+ </div>
238
+ </el-card>
239
+ </el-col>
240
+
241
+ <!-- Stations -->
242
+ <el-col :span="18">
243
+ <div style="display: flex; gap: 10px; overflow-x: auto; padding-bottom: 10px;">
244
+ <div v-for="(station, sIndex) in stations" :key="sIndex" class="station-card" style="min-width: 250px; flex: 1;"
245
+ @dragover.prevent @drop="drop($event, sIndex)">
246
+ <div class="station-header">
247
+ <span>Station ${ sIndex + 1 }</span>
248
+ <el-tag :type="getStationTime(station) > metrics.takt_time ? 'danger' : 'success'" effect="dark">
249
+ ${ getStationTime(station) }s
250
+ </el-tag>
251
+ </div>
252
+ <!-- Visual Progress Bar for Station Load -->
253
+ <div style="padding: 0 15px; margin-top: 5px;">
254
+ <div class="takt-line-indicator">
255
+ <div class="takt-fill"
256
+ :class="{ 'overload': getStationTime(station) > metrics.takt_time }"
257
+ :style="{ width: Math.min((getStationTime(station) / (metrics.takt_time || 1)) * 100, 100) + '%' }">
258
+ </div>
259
+ </div>
260
+ <div style="font-size: 10px; color: #909399; text-align: right; margin-top: 2px;">
261
+ Load: ${ Math.round((getStationTime(station) / (metrics.takt_time || 1)) * 100) }%
262
+ </div>
263
+ </div>
264
+
265
+ <div class="station-body">
266
+ <div v-for="(task, tIndex) in station" :key="task.id" class="task-item" draggable="true" @dragstart="dragStart($event, task, sIndex)">
267
+ <div>
268
+ <span>${ task.name }</span>
269
+ <span style="font-size: 12px; margin-left: 5px; color: #606266;">(${ task.duration }s)</span>
270
+ </div>
271
+ <el-button type="text" icon="Close" size="small" style="color: #F56C6C;" @click="removeTask(sIndex, tIndex)"></el-button>
272
+ </div>
273
+ <div v-if="station.length === 0" style="text-align: center; color: #C0C4CC; padding: 10px; border: 2px dashed #EBEEF5; border-radius: 4px;">
274
+ 拖拽工序到此处
275
+ </div>
276
+ </div>
277
+ <div style="padding: 10px; border-top: 1px solid #ebeef5; text-align: center;">
278
+ <el-button size="small" @click="deleteStation(sIndex)" :disabled="station.length > 0">删除工位</el-button>
279
+ </div>
280
+ </div>
281
+
282
+ <!-- Add Station Button -->
283
+ <div style="min-width: 100px; display: flex; align-items: center; justify-content: center;">
284
+ <el-button type="primary" circle icon="Plus" size="large" @click="addStation"></el-button>
285
+ </div>
286
+ </div>
287
+ </el-col>
288
+ </el-row>
289
+ </div>
290
+
291
+ <!-- Add Task Dialog -->
292
+ <el-dialog v-model="dialogVisible" title="添加新工序">
293
+ <el-form :model="newTask">
294
+ <el-form-item label="工序名称">
295
+ <el-input v-model="newTask.name"></el-input>
296
+ </el-form-item>
297
+ <el-form-item label="标准工时 (秒)">
298
+ <el-input-number v-model="newTask.duration" :min="1"></el-input-number>
299
+ </el-form-item>
300
+ </el-form>
301
+ <template #footer>
302
+ <el-button @click="dialogVisible = false">取消</el-button>
303
+ <el-button type="primary" @click="addTask">确定</el-button>
304
+ </template>
305
+ </el-dialog>
306
+ </div>
307
+
308
+ <!-- JS Dependencies -->
309
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
310
+ <script src="https://unpkg.com/element-plus"></script>
311
+ <script src="https://unpkg.com/echarts/dist/echarts.min.js"></script>
312
+ <script src="https://unpkg.com/@element-plus/icons-vue"></script>
313
+
314
+ <script>
315
+ const { createApp, ref, reactive, computed, onMounted, nextTick } = Vue;
316
+
317
+ const app = createApp({
318
+ delimiters: ['${', '}'],
319
+ setup() {
320
+ const config = reactive({
321
+ work_hours: 8,
322
+ target_output: 100
323
+ });
324
+
325
+ const taskPool = ref([]);
326
+ const stations = ref([[], [], []]); // Initial 3 empty stations
327
+ const metrics = reactive({
328
+ takt_time: 0,
329
+ bottleneck_time: 0,
330
+ bottleneck_station: 0,
331
+ line_efficiency: 0,
332
+ smoothness_index: 0,
333
+ daily_capacity: 0
334
+ });
335
+
336
+ const loading = ref(false);
337
+ const dialogVisible = ref(false);
338
+ const newTask = reactive({ name: '', duration: 10 });
339
+ let chartInstance = null;
340
+
341
+ // Drag & Drop State
342
+ let draggedTask = null;
343
+ let sourceStationIndex = -1; // -1 means from Task Pool
344
+
345
+ // Initial Data Load
346
+ const loadDefaultData = () => {
347
+ const defaultTasks = [
348
+ {id: "t1", name: "安装底壳", duration: 15},
349
+ {id: "t2", name: "安装主板", duration: 25},
350
+ {id: "t3", name: "连接电源线", duration: 10},
351
+ {id: "t4", name: "安装风扇", duration: 12},
352
+ {id: "t5", name: "固定硬盘", duration: 18},
353
+ {id: "t6", name: "安装显卡", duration: 20},
354
+ {id: "t7", name: "理线", duration: 30},
355
+ {id: "t8", name: "合盖", duration: 10},
356
+ {id: "t9", name: "贴标签", duration: 5},
357
+ {id: "t10", name: "最终测试", duration: 40}
358
+ ];
359
+ // Distribute some tasks to stations for demo
360
+ stations.value = [
361
+ [defaultTasks[0], defaultTasks[1]],
362
+ [defaultTasks[2], defaultTasks[3], defaultTasks[4]],
363
+ [defaultTasks[5], defaultTasks[6]],
364
+ [defaultTasks[7], defaultTasks[8]],
365
+ [defaultTasks[9]]
366
+ ];
367
+ taskPool.value = [];
368
+ calculateMetrics();
369
+ };
370
+
371
+ const calculateMetrics = async () => {
372
+ try {
373
+ const response = await fetch('/api/calculate', {
374
+ method: 'POST',
375
+ headers: {'Content-Type': 'application/json'},
376
+ body: JSON.stringify({
377
+ stations: stations.value,
378
+ config: config
379
+ })
380
+ });
381
+ const data = await response.json();
382
+ Object.assign(metrics, data.metrics);
383
+ updateChart(data.station_times, data.metrics.takt_time);
384
+ } catch (e) {
385
+ console.error(e);
386
+ }
387
+ };
388
+
389
+ const autoBalance = async () => {
390
+ loading.value = true;
391
+ try {
392
+ // Gather all tasks
393
+ let allTasks = [...taskPool.value];
394
+ stations.value.forEach(s => allTasks = allTasks.concat(s));
395
+
396
+ const response = await fetch('/api/auto_balance', {
397
+ method: 'POST',
398
+ headers: {'Content-Type': 'application/json'},
399
+ body: JSON.stringify({
400
+ tasks: allTasks,
401
+ config: config
402
+ })
403
+ });
404
+ const data = await response.json();
405
+ stations.value = data.stations;
406
+ taskPool.value = []; // All tasks assigned
407
+ calculateMetrics();
408
+ ElementPlus.ElMessage.success('自动平衡完成!');
409
+ } catch (e) {
410
+ ElementPlus.ElMessage.error('自动平衡失败');
411
+ } finally {
412
+ loading.value = false;
413
+ }
414
+ };
415
+
416
+ const updateChart = (stationTimes, taktTime) => {
417
+ if (!chartInstance) {
418
+ chartInstance = echarts.init(document.getElementById('balanceChart'));
419
+ }
420
+ const option = {
421
+ title: { text: '山积图 (Yamazumi Chart)', left: 'center', textStyle: { fontSize: 14 } },
422
+ tooltip: { trigger: 'axis' },
423
+ grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
424
+ xAxis: {
425
+ type: 'category',
426
+ data: stationTimes.map((_, i) => `ST-${i+1}`)
427
+ },
428
+ yAxis: { type: 'value', name: '时间 (秒)' },
429
+ series: [
430
+ {
431
+ name: '工位时间',
432
+ type: 'bar',
433
+ data: stationTimes,
434
+ itemStyle: {
435
+ color: (params) => {
436
+ return params.value > taktTime ? '#F56C6C' : '#409EFF';
437
+ }
438
+ },
439
+ markLine: {
440
+ data: [
441
+ {
442
+ yAxis: taktTime,
443
+ name: 'Takt Time',
444
+ lineStyle: { color: '#67C23A', width: 2, type: 'dashed' },
445
+ label: { formatter: 'Takt: {c}s' }
446
+ }
447
+ ]
448
+ }
449
+ }
450
+ ]
451
+ };
452
+ chartInstance.setOption(option);
453
+ };
454
+
455
+ // Helper
456
+ const getStationTime = (station) => {
457
+ return station.reduce((sum, t) => sum + t.duration, 0);
458
+ };
459
+
460
+ // Drag & Drop Handlers
461
+ const dragStart = (event, task, sIndex) => {
462
+ draggedTask = task;
463
+ sourceStationIndex = sIndex;
464
+ event.dataTransfer.effectAllowed = 'move';
465
+ };
466
+
467
+ const drop = (event, targetStationIndex) => {
468
+ if (!draggedTask) return;
469
+
470
+ // Remove from source
471
+ if (sourceStationIndex === -1) {
472
+ taskPool.value = taskPool.value.filter(t => t.id !== draggedTask.id);
473
+ } else {
474
+ stations.value[sourceStationIndex] = stations.value[sourceStationIndex].filter(t => t.id !== draggedTask.id);
475
+ }
476
+
477
+ // Add to target
478
+ stations.value[targetStationIndex].push(draggedTask);
479
+
480
+ // Reset
481
+ draggedTask = null;
482
+ sourceStationIndex = -1;
483
+
484
+ // Recalculate
485
+ calculateMetrics();
486
+ };
487
+
488
+ // CRUD
489
+ const addStation = () => {
490
+ stations.value.push([]);
491
+ calculateMetrics();
492
+ };
493
+
494
+ const deleteStation = (index) => {
495
+ if (stations.value[index].length > 0) return;
496
+ stations.value.splice(index, 1);
497
+ calculateMetrics();
498
+ };
499
+
500
+ const removeTask = (sIndex, tIndex) => {
501
+ const task = stations.value[sIndex][tIndex];
502
+ stations.value[sIndex].splice(tIndex, 1);
503
+ taskPool.value.push(task);
504
+ calculateMetrics();
505
+ };
506
+
507
+ const openTaskDialog = () => {
508
+ newTask.name = '';
509
+ newTask.duration = 10;
510
+ dialogVisible.value = true;
511
+ };
512
+
513
+ const addTask = () => {
514
+ if (!newTask.name) return;
515
+ const id = 't' + Date.now();
516
+ taskPool.value.push({
517
+ id: id,
518
+ name: newTask.name,
519
+ duration: newTask.duration
520
+ });
521
+ dialogVisible.value = false;
522
+ };
523
+
524
+ const resetData = () => {
525
+ loadDefaultData();
526
+ };
527
+
528
+ const fileInput = ref(null);
529
+
530
+ const triggerUpload = () => {
531
+ fileInput.value.click();
532
+ };
533
+
534
+ const handleFileChange = (event) => {
535
+ const file = event.target.files[0];
536
+ if (!file) return;
537
+
538
+ const formData = new FormData();
539
+ formData.append('file', file);
540
+
541
+ loading.value = true;
542
+ fetch('/api/upload', {
543
+ method: 'POST',
544
+ body: formData
545
+ })
546
+ .then(response => response.json())
547
+ .then(data => {
548
+ if (data.error) {
549
+ ElementPlus.ElMessage.error(data.error);
550
+ } else {
551
+ if (data.data.stations) stations.value = data.data.stations;
552
+ if (data.data.taskPool) taskPool.value = data.data.taskPool;
553
+ if (data.data.config) Object.assign(config, data.data.config);
554
+ calculateMetrics();
555
+ ElementPlus.ElMessage.success('导入成功');
556
+ }
557
+ })
558
+ .catch(err => {
559
+ ElementPlus.ElMessage.error('上传失败: ' + err);
560
+ })
561
+ .finally(() => {
562
+ loading.value = false;
563
+ event.target.value = '';
564
+ });
565
+ };
566
+
567
+ const exportData = () => {
568
+ const data = {
569
+ stations: stations.value,
570
+ taskPool: taskPool.value,
571
+ config: config
572
+ };
573
+ const blob = new Blob([JSON.stringify(data, null, 2)], {type: "application/json"});
574
+ const url = URL.createObjectURL(blob);
575
+ const a = document.createElement('a');
576
+ a.href = url;
577
+ a.download = 'assembly_line_config.json';
578
+ a.click();
579
+ URL.revokeObjectURL(url);
580
+ };
581
+
582
+ onMounted(() => {
583
+ loadDefaultData();
584
+ window.addEventListener('resize', () => chartInstance && chartInstance.resize());
585
+ });
586
+
587
+ return {
588
+ config, stations, taskPool, metrics, loading, dialogVisible, newTask,
589
+ calculateMetrics, autoBalance, getStationTime,
590
+ dragStart, drop, addStation, deleteStation, removeTask,
591
+ openTaskDialog, addTask, resetData,
592
+ fileInput, triggerUpload, handleFileChange, exportData
593
+ };
594
+ }
595
+ });
596
+
597
+ // Register Icons
598
+ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
599
+ app.component(key, component);
600
+ }
601
+
602
+ app.use(ElementPlus);
603
+ app.mount('#app');
604
+ </script>
605
+ </body>
606
+ </html>