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

Enhance functionality: Import/Export, Localization, and UI improvements

Browse files
Files changed (6) hide show
  1. Dockerfile +12 -0
  2. README.md +48 -0
  3. __pycache__/app.cpython-314.pyc +0 -0
  4. app.py +233 -0
  5. requirements.txt +5 -0
  6. templates/index.html +526 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-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
+ EXPOSE 7860
11
+
12
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Assembly Line Balancer
3
+ emoji: 🏭
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # 产线平衡大师 (Assembly Line Balancer)
12
+
13
+ 这是一个基于 Flask 和 Vue.js 的产线平衡工具 (Assembly Line Balancing Problem Solver)。
14
+ 它可以帮助你计算和优化装配线的工序分配,提高效率,减少平衡损失。
15
+
16
+ ## 功能特性
17
+
18
+ - **自动平衡计算**: 使用 RPW (Ranked Positional Weight) 启发式算法进行产线平衡。
19
+ - **可视化图表**:
20
+ - 工位负荷图 (Station Load Chart)
21
+ - 工序优先关系图 (Precedence Graph)
22
+ - **交互式编辑**: 直接在网页上添加、删除、修改工序和时间。
23
+ - **数据导入导出**:
24
+ - 支持导入 CSV/Excel 文件 (列名: ID, Duration, Predecessors)
25
+ - 支持导出平衡结果为 CSV
26
+ - **多语言支持**: 界面完全汉化。
27
+
28
+ ## 使用说明
29
+
30
+ 1. **设置目标节拍 (Cycle Time)**: 输入期望的生产节拍。
31
+ 2. **输入工序数据**:
32
+ - ID: 工序编号
33
+ - Duration: 工序标准时间
34
+ - Predecessors: 紧前工序 (用逗号分隔)
35
+ 3. **点击“开始平衡计算”**: 系统将自动生成分配方案。
36
+ 4. **查看结果**:
37
+ - 检查平衡率、平滑指数等指标。
38
+ - 查看工位负荷图,识别瓶颈。
39
+ - 导出结果用于生产排程。
40
+
41
+ ## 部署
42
+
43
+ 本项目支持 Docker 部署。
44
+
45
+ ```bash
46
+ docker build -t assembly-line-balancer .
47
+ docker run -p 7860:7860 assembly-line-balancer
48
+ ```
__pycache__/app.cpython-314.pyc ADDED
Binary file (7.5 kB). View file
 
app.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import networkx as nx
3
+ import pandas as pd
4
+ from flask import Flask, render_template, request, jsonify
5
+
6
+ app = Flask(__name__)
7
+
8
+ def parse_file(file):
9
+ """
10
+ Parse uploaded file (CSV or Excel) into tasks list.
11
+ Expected columns: ID, Duration, Predecessors
12
+ """
13
+ filename = file.filename
14
+ if filename.endswith('.csv'):
15
+ df = pd.read_csv(file)
16
+ elif filename.endswith(('.xls', '.xlsx')):
17
+ df = pd.read_excel(file)
18
+ else:
19
+ raise ValueError("Unsupported file format. Please upload CSV or Excel.")
20
+
21
+ # Normalize columns
22
+ df.columns = [c.lower().strip() for c in df.columns]
23
+
24
+ # Map common column names
25
+ col_map = {
26
+ 'id': 'id', 'task': 'id', 'task_id': 'id',
27
+ 'duration': 'duration', 'time': 'duration',
28
+ 'predecessors': 'predecessors', 'dependencies': 'predecessors', 'preds': 'predecessors'
29
+ }
30
+
31
+ # Rename columns based on map
32
+ df = df.rename(columns=lambda x: col_map.get(x, x))
33
+
34
+ required = ['id', 'duration']
35
+ for req in required:
36
+ if req not in df.columns:
37
+ raise ValueError(f"Missing required column: {req}")
38
+
39
+ tasks = []
40
+ for _, row in df.iterrows():
41
+ # Handle predecessors
42
+ preds = []
43
+ if 'predecessors' in df.columns and pd.notna(row['predecessors']):
44
+ p_val = str(row['predecessors'])
45
+ # Split by comma or semicolon
46
+ preds = [p.strip() for p in p_val.replace(';', ',').split(',') if p.strip()]
47
+
48
+ tasks.append({
49
+ 'id': str(row['id']),
50
+ 'duration': float(row['duration']),
51
+ 'predecessors': preds
52
+ })
53
+
54
+ return tasks
55
+
56
+ def calculate_rpw(tasks):
57
+ """
58
+ Calculate Ranked Positional Weight for each task.
59
+ RPW = Task Duration + Sum of durations of all successors.
60
+ """
61
+ G = nx.DiGraph()
62
+ task_map = {t['id']: t for t in tasks}
63
+
64
+ # Add nodes and edges
65
+ for task in tasks:
66
+ G.add_node(task['id'], duration=float(task['duration']))
67
+ for pred in task.get('predecessors', []):
68
+ if pred: # Ignore empty strings
69
+ G.add_edge(pred, task['id'])
70
+
71
+ if not nx.is_directed_acyclic_graph(G):
72
+ raise ValueError("Dependencies contain a cycle (circular reference).")
73
+
74
+ rpw_values = {}
75
+ for node in G.nodes():
76
+ # Get all successors
77
+ successors = nx.descendants(G, node)
78
+ successor_duration = sum(G.nodes[s]['duration'] for s in successors)
79
+ rpw_values[node] = G.nodes[node]['duration'] + successor_duration
80
+
81
+ return rpw_values, G
82
+
83
+ def balance_line(tasks, cycle_time):
84
+ """
85
+ Balance the assembly line using RPW Heuristic.
86
+ """
87
+ # 1. Validation
88
+ for t in tasks:
89
+ if float(t['duration']) > cycle_time:
90
+ raise ValueError(f"Task {t['id']} duration ({t['duration']}) exceeds Cycle Time ({cycle_time}). Impossible to balance.")
91
+
92
+ # 2. Calculate RPW
93
+ rpw_map, G = calculate_rpw(tasks)
94
+
95
+ # 3. Sort tasks by RPW descending
96
+ # Convert to list for processing
97
+ sorted_tasks = sorted(tasks, key=lambda x: rpw_map.get(x['id'], 0), reverse=True)
98
+
99
+ stations = []
100
+ current_station = {'id': 1, 'tasks': [], 'time_used': 0, 'time_left': cycle_time}
101
+
102
+ completed_tasks = set()
103
+ all_task_ids = set(t['id'] for t in tasks)
104
+ assigned_tasks = set()
105
+
106
+ while len(assigned_tasks) < len(all_task_ids):
107
+ # Find candidates:
108
+ # - Not yet assigned
109
+ # - All predecessors completed
110
+ # - Fits in current station
111
+ candidates = []
112
+ for t in sorted_tasks:
113
+ tid = t['id']
114
+ if tid in assigned_tasks:
115
+ continue
116
+
117
+ preds = t.get('predecessors', [])
118
+ # Check if all preds are completed (assigned to *previous* stations OR *current* station)
119
+ # Actually, for line balancing, "completed" means assigned to a station (could be current).
120
+ # But strict precedence means:
121
+ # If A -> B, A must be assigned before B.
122
+ # And physically, A must be done before B.
123
+ # If A and B are in same station, A must be scheduled before B in that station list.
124
+ # Our logic assigns sequentially, so order in 'assigned_tasks' matters.
125
+
126
+ # Check predecessors
127
+ preds_met = all(p in completed_tasks for p in preds if p)
128
+
129
+ if preds_met:
130
+ if float(t['duration']) <= current_station['time_left']:
131
+ candidates.append(t)
132
+
133
+ if candidates:
134
+ # Pick the one with highest RPW (already sorted)
135
+ best_task = candidates[0]
136
+
137
+ # Assign
138
+ current_station['tasks'].append(best_task)
139
+ dur = float(best_task['duration'])
140
+ current_station['time_used'] += dur
141
+ current_station['time_left'] -= dur
142
+
143
+ assigned_tasks.add(best_task['id'])
144
+ completed_tasks.add(best_task['id'])
145
+
146
+ else:
147
+ # No candidates fit in current station
148
+ # Check if we have unassigned tasks that CANNOT fit (waiting for preds?)
149
+ # If there are unassigned tasks, but no candidates, it means:
150
+ # Either:
151
+ # 1. Tasks exist but don't fit in REMAINING time -> New Station
152
+ # 2. Tasks exist but predecessors not met -> Wait (but we iterate sorted_tasks)
153
+
154
+ # If we couldn't find ANY task for current station, but there are still tasks left,
155
+ # we must open a new station.
156
+ if len(assigned_tasks) < len(all_task_ids):
157
+ stations.append(current_station)
158
+ new_id = len(stations) + 1
159
+ current_station = {'id': new_id, 'tasks': [], 'time_used': 0, 'time_left': cycle_time}
160
+ else:
161
+ break
162
+
163
+ # Append last station
164
+ if current_station['tasks']:
165
+ stations.append(current_station)
166
+
167
+ # Metrics
168
+ total_task_time = sum(float(t['duration']) for t in tasks)
169
+ num_stations = len(stations)
170
+ efficiency = (total_task_time / (num_stations * cycle_time)) * 100 if num_stations > 0 else 0
171
+ balance_delay = 100 - efficiency
172
+
173
+ # Smoothness Index (SX)
174
+ # SX = sqrt(sum( (max_station_time - station_time)^2 ))
175
+ max_st_time = max(s['time_used'] for s in stations) if stations else 0
176
+ smoothness_index = (sum((max_st_time - s['time_used'])**2 for s in stations))**0.5
177
+
178
+ return {
179
+ 'stations': stations,
180
+ 'metrics': {
181
+ 'efficiency': round(efficiency, 2),
182
+ 'balance_delay': round(balance_delay, 2),
183
+ 'smoothness_index': round(smoothness_index, 2),
184
+ 'num_stations': num_stations,
185
+ 'cycle_time': cycle_time,
186
+ 'total_time': total_task_time
187
+ }
188
+ }
189
+
190
+ @app.route('/')
191
+ def index():
192
+ return render_template('index.html')
193
+
194
+ @app.route('/api/calculate', methods=['POST'])
195
+ def calculate():
196
+ try:
197
+ data = request.json
198
+ tasks = data.get('tasks', [])
199
+ cycle_time = float(data.get('cycle_time', 0))
200
+
201
+ if cycle_time <= 0:
202
+ return jsonify({'error': 'Cycle time must be positive'}), 400
203
+ if not tasks:
204
+ return jsonify({'error': 'No tasks provided'}), 400
205
+
206
+ result = balance_line(tasks, cycle_time)
207
+ return jsonify(result)
208
+
209
+ except ValueError as e:
210
+ return jsonify({'error': str(e)}), 400
211
+ except Exception as e:
212
+ return jsonify({'error': f"Server Error: {str(e)}"}), 500
213
+
214
+ @app.route('/api/upload', methods=['POST'])
215
+ def upload_file():
216
+ try:
217
+ if 'file' not in request.files:
218
+ return jsonify({'error': 'No file part'}), 400
219
+
220
+ file = request.files['file']
221
+ if file.filename == '':
222
+ return jsonify({'error': 'No selected file'}), 400
223
+
224
+ tasks = parse_file(file)
225
+ return jsonify({'tasks': tasks})
226
+
227
+ except ValueError as e:
228
+ return jsonify({'error': str(e)}), 400
229
+ except Exception as e:
230
+ return jsonify({'error': f"Upload failed: {str(e)}"}), 500
231
+
232
+ if __name__ == '__main__':
233
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask>=2.0.0
2
+ networkx>=3.0
3
+ gunicorn>=20.0.0
4
+ pandas>=2.0.0
5
+ openpyxl>=3.1.0
templates/index.html ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Balancer | 产线平衡大师</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://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
10
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
11
+ <style>
12
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
13
+ body { font-family: 'Inter', sans-serif; background-color: #f3f4f6; }
14
+ .chart-container { height: 400px; width: 100%; }
15
+ /* Custom scrollbar */
16
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
17
+ ::-webkit-scrollbar-track { background: #f1f1f1; }
18
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
19
+ ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <div id="app" class="min-h-screen flex flex-col">
24
+ <!-- Header -->
25
+ <header class="bg-white shadow-sm z-10">
26
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
27
+ <div class="flex items-center gap-3">
28
+ <div class="bg-indigo-600 p-2 rounded-lg text-white">
29
+ <i class="fa-solid fa-industry text-xl"></i>
30
+ </div>
31
+ <h1 class="text-xl font-bold text-gray-900">产线平衡大师 <span class="text-gray-400 text-sm font-normal">| Assembly Line Balancer</span></h1>
32
+ </div>
33
+ <div class="flex items-center gap-4">
34
+ <button @click="triggerUpload" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">
35
+ <i class="fa-solid fa-upload mr-1"></i> 导入数据
36
+ </button>
37
+ <button @click="exportResult" :disabled="!stations.length" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium disabled:opacity-50 disabled:cursor-not-allowed">
38
+ <i class="fa-solid fa-download mr-1"></i> 导出结果
39
+ </button>
40
+ <button @click="loadDemoData" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">
41
+ <i class="fa-solid fa-magic mr-1"></i> 加载演示数据
42
+ </button>
43
+ <a href="https://huggingface.co/spaces" target="_blank" class="text-gray-500 hover:text-gray-700">
44
+ <i class="fa-brands fa-github text-xl"></i>
45
+ </a>
46
+ </div>
47
+ <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept=".csv,.xlsx,.xls">
48
+ </div>
49
+ </header>
50
+
51
+ <!-- Main Content -->
52
+ <main class="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full gap-6 grid grid-cols-1 lg:grid-cols-12">
53
+
54
+ <!-- Left Panel: Controls & Data -->
55
+ <div class="lg:col-span-4 space-y-6">
56
+
57
+ <!-- Settings Card -->
58
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
59
+ <h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
60
+ <i class="fa-solid fa-sliders mr-2 text-indigo-500"></i> 参数设置
61
+ </h2>
62
+
63
+ <div class="space-y-4">
64
+ <div>
65
+ <label class="block text-sm font-medium text-gray-700 mb-1">目标节拍 (Cycle Time)</label>
66
+ <div class="relative rounded-md shadow-sm">
67
+ <input type="number" v-model.number="cycleTime" step="0.1" min="0.1"
68
+ class="block w-full rounded-md border-gray-300 pl-3 pr-12 focus:border-indigo-500 focus:ring-indigo-500 py-2 border sm:text-sm"
69
+ placeholder="例如: 10">
70
+ <div class="absolute inset-y-0 right-0 flex items-center pr-3">
71
+ <span class="text-gray-500 sm:text-sm">秒</span>
72
+ </div>
73
+ </div>
74
+ <p class="mt-1 text-xs text-gray-500">决定产线的最大产出速度</p>
75
+ </div>
76
+
77
+ <div class="pt-2">
78
+ <button @click="calculate"
79
+ :disabled="loading"
80
+ class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
81
+ <span v-if="loading"><i class="fa-solid fa-circle-notch fa-spin mr-2"></i> 计算中...</span>
82
+ <span v-else><i class="fa-solid fa-calculator mr-2"></i> 开始平衡计算</span>
83
+ </button>
84
+ </div>
85
+
86
+ <!-- Error Message -->
87
+ <div v-if="error" class="bg-red-50 border-l-4 border-red-500 p-4 mt-4">
88
+ <div class="flex">
89
+ <div class="flex-shrink-0">
90
+ <i class="fa-solid fa-circle-exclamation text-red-400"></i>
91
+ </div>
92
+ <div class="ml-3">
93
+ <p class="text-sm text-red-700">${ error }</p>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Tasks Editor -->
101
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex flex-col h-[600px]">
102
+ <div class="flex justify-between items-center mb-4">
103
+ <h2 class="text-lg font-semibold text-gray-800 flex items-center">
104
+ <i class="fa-solid fa-list-check mr-2 text-indigo-500"></i> 工序列表
105
+ </h2>
106
+ <button @click="addTask" class="p-1.5 bg-gray-100 rounded-md hover:bg-gray-200 text-gray-600 transition-colors" title="添加工序">
107
+ <i class="fa-solid fa-plus"></i>
108
+ </button>
109
+ </div>
110
+
111
+ <div class="flex-1 overflow-auto -mx-2 px-2">
112
+ <table class="min-w-full divide-y divide-gray-200">
113
+ <thead class="bg-gray-50 sticky top-0">
114
+ <tr>
115
+ <th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">ID</th>
116
+ <th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-24">工时</th>
117
+ <th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">紧前工序</th>
118
+ <th scope="col" class="w-10"></th>
119
+ </tr>
120
+ </thead>
121
+ <tbody class="bg-white divide-y divide-gray-200">
122
+ <tr v-for="(task, index) in tasks" :key="index" class="hover:bg-gray-50 group">
123
+ <td class="px-3 py-2">
124
+ <input type="text" v-model="task.id" class="w-full border-gray-200 rounded text-sm focus:ring-indigo-500 focus:border-indigo-500 px-1 py-0.5" placeholder="A">
125
+ </td>
126
+ <td class="px-3 py-2">
127
+ <input type="number" v-model.number="task.duration" step="0.1" class="w-full border-gray-200 rounded text-sm focus:ring-indigo-500 focus:border-indigo-500 px-1 py-0.5">
128
+ </td>
129
+ <td class="px-3 py-2">
130
+ <input type="text" :value="task.predecessors.join(',')" @input="updatePreds(index, $event.target.value)"
131
+ class="w-full border-gray-200 rounded text-sm focus:ring-indigo-500 focus:border-indigo-500 px-1 py-0.5"
132
+ placeholder="A,B">
133
+ </td>
134
+ <td class="px-3 py-2 text-right">
135
+ <button @click="removeTask(index)" class="text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
136
+ <i class="fa-solid fa-trash-can"></i>
137
+ </button>
138
+ </td>
139
+ </tr>
140
+ </tbody>
141
+ </table>
142
+ </div>
143
+ <div class="mt-2 text-xs text-gray-400 text-center">
144
+ 提示: 紧前工序用逗号分隔
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- Right Panel: Visualization -->
150
+ <div class="lg:col-span-8 space-y-6">
151
+
152
+ <!-- Metrics Grid -->
153
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
154
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
155
+ <div class="flex items-center justify-between mb-2">
156
+ <h3 class="text-sm font-medium text-gray-500">产线平衡率 (Efficiency)</h3>
157
+ <i class="fa-solid fa-chart-pie text-indigo-500"></i>
158
+ </div>
159
+ <div class="flex items-end">
160
+ <span class="text-3xl font-bold text-gray-900">${ metrics.efficiency || 0 }</span>
161
+ <span class="text-lg text-gray-600 mb-1 ml-1">%</span>
162
+ </div>
163
+ <div class="mt-2 w-full bg-gray-200 rounded-full h-1.5">
164
+ <div class="bg-indigo-600 h-1.5 rounded-full transition-all duration-500" :style="{ width: (metrics.efficiency || 0) + '%' }"></div>
165
+ </div>
166
+ </div>
167
+
168
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
169
+ <div class="flex items-center justify-between mb-2">
170
+ <h3 class="text-sm font-medium text-gray-500">工位数量 (Stations)</h3>
171
+ <i class="fa-solid fa-people-group text-indigo-500"></i>
172
+ </div>
173
+ <div class="flex items-end">
174
+ <span class="text-3xl font-bold text-gray-900">${ metrics.num_stations || 0 }</span>
175
+ <span class="text-sm text-gray-500 mb-1 ml-2">个</span>
176
+ </div>
177
+ <p class="text-xs text-gray-400 mt-2">平衡损失率: ${ metrics.balance_delay || 0 }%</p>
178
+ </div>
179
+
180
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
181
+ <div class="flex items-center justify-between mb-2">
182
+ <h3 class="text-sm font-medium text-gray-500">平滑指数 (SX)</h3>
183
+ <i class="fa-solid fa-wave-square text-indigo-500"></i>
184
+ </div>
185
+ <div class="flex items-end">
186
+ <span class="text-3xl font-bold text-gray-900">${ metrics.smoothness_index || 0 }</span>
187
+ </div>
188
+ <p class="text-xs text-gray-400 mt-2">数值越小越平滑</p>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Station Chart -->
193
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
194
+ <h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
195
+ <i class="fa-solid fa-chart-simple mr-2 text-indigo-500"></i> 工位负荷图
196
+ </h2>
197
+ <div id="stationChart" class="chart-container"></div>
198
+ </div>
199
+
200
+ <!-- Dependency Graph -->
201
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
202
+ <h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
203
+ <i class="fa-solid fa-project-diagram mr-2 text-indigo-500"></i> 工序优先关系图
204
+ </h2>
205
+ <div id="graphChart" class="chart-container"></div>
206
+ </div>
207
+
208
+ <!-- Detailed Station List -->
209
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6" v-if="stations.length > 0">
210
+ <h2 class="text-lg font-semibold text-gray-800 mb-4">详细分配方案</h2>
211
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
212
+ <div v-for="station in stations" :key="station.id" class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
213
+ <div class="flex justify-between items-center mb-3">
214
+ <span class="font-bold text-gray-700">工位 ${ station.id }</span>
215
+ <span :class="{'text-green-600': station.time_used <= cycleTime, 'text-red-600': station.time_used > cycleTime}" class="text-sm font-mono">
216
+ ${ station.time_used.toFixed(1) } / ${ cycleTime }s
217
+ </span>
218
+ </div>
219
+ <div class="w-full bg-gray-100 rounded-full h-2 mb-3">
220
+ <div class="h-2 rounded-full transition-all"
221
+ :class="getLoadColor(station.time_used, cycleTime)"
222
+ :style="{ width: Math.min((station.time_used / cycleTime) * 100, 100) + '%' }"></div>
223
+ </div>
224
+ <div class="flex flex-wrap gap-2">
225
+ <span v-for="t in station.tasks" :key="t.id" class="px-2 py-1 bg-indigo-50 text-indigo-700 text-xs rounded border border-indigo-100 font-medium">
226
+ ${ t.id } (${ t.duration })
227
+ </span>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </main>
234
+ </div>
235
+
236
+ <script>
237
+ const { createApp, ref, onMounted, nextTick } = Vue;
238
+
239
+ createApp({
240
+ delimiters: ['${', '}'],
241
+ setup() {
242
+ const loading = ref(false);
243
+ const error = ref(null);
244
+ const cycleTime = ref(10);
245
+ const fileInput = ref(null);
246
+ const tasks = ref([
247
+ { id: '1', duration: 5, predecessors: [] },
248
+ { id: '2', duration: 3, predecessors: ['1'] },
249
+ { id: '3', duration: 4, predecessors: ['1'] },
250
+ { id: '4', duration: 3, predecessors: ['2', '3'] },
251
+ { id: '5', duration: 6, predecessors: ['4'] }
252
+ ]);
253
+ const stations = ref([]);
254
+ const metrics = ref({});
255
+
256
+ let stationChart = null;
257
+ let graphChart = null;
258
+
259
+ const updatePreds = (index, value) => {
260
+ const arr = value.split(',').map(s => s.trim()).filter(s => s);
261
+ tasks.value[index].predecessors = arr;
262
+ };
263
+
264
+ const addTask = () => {
265
+ const nextId = (tasks.value.length + 1).toString();
266
+ tasks.value.push({ id: nextId, duration: 1, predecessors: [] });
267
+ };
268
+
269
+ const removeTask = (index) => {
270
+ tasks.value.splice(index, 1);
271
+ };
272
+
273
+ const triggerUpload = () => {
274
+ fileInput.value.click();
275
+ };
276
+
277
+ const handleFileUpload = async (event) => {
278
+ const file = event.target.files[0];
279
+ if (!file) return;
280
+
281
+ const formData = new FormData();
282
+ formData.append('file', file);
283
+
284
+ loading.value = true;
285
+ error.value = null;
286
+
287
+ try {
288
+ const response = await fetch('/api/upload', {
289
+ method: 'POST',
290
+ body: formData
291
+ });
292
+
293
+ const data = await response.json();
294
+
295
+ if (!response.ok) {
296
+ throw new Error(data.error || 'Upload failed');
297
+ }
298
+
299
+ tasks.value = data.tasks;
300
+ // Clear stations as data changed
301
+ stations.value = [];
302
+ metrics.value = {};
303
+ updateCharts();
304
+
305
+ } catch (e) {
306
+ error.value = e.message;
307
+ } finally {
308
+ loading.value = false;
309
+ // Reset input
310
+ event.target.value = '';
311
+ }
312
+ };
313
+
314
+ const exportResult = () => {
315
+ if (stations.value.length === 0) return;
316
+
317
+ // Simple CSV export
318
+ let csvContent = "data:text/csv;charset=utf-8,";
319
+ csvContent += "Station ID,Task ID,Duration\n";
320
+
321
+ stations.value.forEach(station => {
322
+ station.tasks.forEach(task => {
323
+ csvContent += `${station.id},${task.id},${task.duration}\n`;
324
+ });
325
+ });
326
+
327
+ const encodedUri = encodeURI(csvContent);
328
+ const link = document.createElement("a");
329
+ link.setAttribute("href", encodedUri);
330
+ link.setAttribute("download", "balancing_result.csv");
331
+ document.body.appendChild(link);
332
+ link.click();
333
+ document.body.removeChild(link);
334
+ };
335
+
336
+ const loadDemoData = () => {
337
+ // Classic Jackson (1956) Problem (Simplified) or similar
338
+ tasks.value = [
339
+ { id: '1', duration: 6, predecessors: [] },
340
+ { id: '2', duration: 2, predecessors: [] },
341
+ { id: '3', duration: 5, predecessors: ['1'] },
342
+ { id: '4', duration: 7, predecessors: ['1'] },
343
+ { id: '5', duration: 1, predecessors: ['2'] },
344
+ { id: '6', duration: 2, predecessors: ['2'] },
345
+ { id: '7', duration: 3, predecessors: ['3', '4'] },
346
+ { id: '8', duration: 6, predecessors: ['5', '6'] },
347
+ { id: '9', duration: 5, predecessors: ['7', '8'] },
348
+ { id: '10', duration: 5, predecessors: ['9'] },
349
+ { id: '11', duration: 4, predecessors: ['10'] }
350
+ ];
351
+ cycleTime.value = 10;
352
+ calculate();
353
+ };
354
+
355
+ const calculate = async () => {
356
+ loading.value = true;
357
+ error.value = null;
358
+ try {
359
+ const response = await fetch('/api/calculate', {
360
+ method: 'POST',
361
+ headers: { 'Content-Type': 'application/json' },
362
+ body: JSON.stringify({
363
+ tasks: tasks.value,
364
+ cycle_time: cycleTime.value
365
+ })
366
+ });
367
+
368
+ const data = await response.json();
369
+
370
+ if (!response.ok) {
371
+ throw new Error(data.error || 'Calculation failed');
372
+ }
373
+
374
+ stations.value = data.stations;
375
+ metrics.value = data.metrics;
376
+
377
+ // Update charts
378
+ nextTick(() => {
379
+ updateCharts();
380
+ });
381
+
382
+ } catch (e) {
383
+ error.value = e.message;
384
+ } finally {
385
+ loading.value = false;
386
+ }
387
+ };
388
+
389
+ const getLoadColor = (used, max) => {
390
+ const pct = used / max;
391
+ if (pct > 1) return 'bg-red-500';
392
+ if (pct > 0.9) return 'bg-green-500';
393
+ if (pct > 0.7) return 'bg-blue-500';
394
+ return 'bg-yellow-500';
395
+ };
396
+
397
+ const initCharts = () => {
398
+ const chartDom1 = document.getElementById('stationChart');
399
+ const chartDom2 = document.getElementById('graphChart');
400
+
401
+ if (chartDom1) {
402
+ stationChart = echarts.init(chartDom1);
403
+ window.addEventListener('resize', () => stationChart.resize());
404
+ }
405
+
406
+ if (chartDom2) {
407
+ graphChart = echarts.init(chartDom2);
408
+ window.addEventListener('resize', () => graphChart.resize());
409
+ }
410
+ };
411
+
412
+ const updateCharts = () => {
413
+ if (!stationChart || !graphChart) return;
414
+
415
+ // 1. Station Chart
416
+ const stationIds = stations.value.map(s => `ST-${s.id}`);
417
+ const stationTimes = stations.value.map(s => s.time_used);
418
+ const idleTimes = stations.value.map(s => {
419
+ const val = cycleTime.value - s.time_used;
420
+ return val > 0 ? parseFloat(val.toFixed(1)) : 0;
421
+ });
422
+
423
+ stationChart.setOption({
424
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
425
+ legend: { data: ['作业时间', '空闲时间'] },
426
+ grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
427
+ xAxis: { type: 'category', data: stationIds },
428
+ yAxis: { type: 'value', name: '时间 (秒)' },
429
+ series: [
430
+ {
431
+ name: '作业时间',
432
+ type: 'bar',
433
+ stack: 'total',
434
+ label: { show: true, position: 'inside' },
435
+ data: stationTimes,
436
+ itemStyle: { color: '#4f46e5' }
437
+ },
438
+ {
439
+ name: '空闲时间',
440
+ type: 'bar',
441
+ stack: 'total',
442
+ label: { show: true, position: 'top' },
443
+ data: idleTimes,
444
+ itemStyle: { color: '#e5e7eb' }
445
+ },
446
+ {
447
+ type: 'line',
448
+ data: Array(stationIds.length).fill(cycleTime.value),
449
+ markLine: {
450
+ data: [{ yAxis: cycleTime.value, name: '节拍' }],
451
+ lineStyle: { color: '#ef4444', type: 'dashed' }
452
+ }
453
+ }
454
+ ]
455
+ });
456
+
457
+ // 2. Graph Chart
458
+ // Convert tasks to nodes and edges
459
+ const nodes = tasks.value.map(t => ({
460
+ name: t.id,
461
+ value: t.duration,
462
+ symbolSize: 40,
463
+ itemStyle: { color: '#4f46e5' },
464
+ label: { show: true, color: '#fff' }
465
+ }));
466
+
467
+ const edges = [];
468
+ tasks.value.forEach(t => {
469
+ t.predecessors.forEach(p => {
470
+ if (p && tasks.value.find(task => task.id === p)) {
471
+ edges.push({
472
+ source: p,
473
+ target: t.id,
474
+ lineStyle: { curveness: 0.2 }
475
+ });
476
+ }
477
+ });
478
+ });
479
+
480
+ graphChart.setOption({
481
+ title: { text: '任务关系图', left: 'center' },
482
+ tooltip: {},
483
+ series: [
484
+ {
485
+ type: 'graph',
486
+ layout: 'force',
487
+ animation: false,
488
+ data: nodes,
489
+ links: edges,
490
+ roam: true,
491
+ label: { position: 'right' },
492
+ force: { repulsion: 300, edgeLength: 50 }
493
+ }
494
+ ]
495
+ });
496
+ };
497
+
498
+ onMounted(() => {
499
+ initCharts();
500
+ // Load default data on start
501
+ loadDemoData();
502
+ });
503
+
504
+ return {
505
+ loading,
506
+ error,
507
+ cycleTime,
508
+ tasks,
509
+ stations,
510
+ metrics,
511
+ fileInput,
512
+ updatePreds,
513
+ addTask,
514
+ removeTask,
515
+ loadDemoData,
516
+ calculate,
517
+ getLoadColor,
518
+ triggerUpload,
519
+ handleFileUpload,
520
+ exportResult
521
+ };
522
+ }
523
+ }).mount('#app');
524
+ </script>
525
+ </body>
526
+ </html>