@@ -193,7 +209,6 @@
-
@@ -205,6 +220,7 @@
+
diff --git a/frontend/js/app.js b/frontend/js/app.js
index 816efe4..dbd56e1 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -79,9 +79,10 @@
document.getElementById('overlay').classList.remove('hidden');
try {
currentUser = await API.me().catch(() => null);
- const publicRoutes = ['/login'];
- if ((!currentUser || !currentUser.username) && !publicRoutes.includes(path)) {
- location.hash = '#/login';
+ if (!currentUser || !currentUser.username) {
+ // 未登录,重定向到独立登录页面
+ window.location.href = './login.html';
+ return;
}
} catch(e) {}
});
@@ -106,7 +107,10 @@
if (user && (user.role === 'superadmin' || user.role === 'admin') && window.NotificationSystem) {
window.NotificationSystem.init();
}
- }).catch(()=>{});
+ }).catch(() => {
+ // 未登录,重定向到独立登录页面
+ window.location.href = './login.html';
+ });
// 用户头像按钮点击事件 - 显示/隐藏下拉菜单
const userAvatarBtn = document.getElementById('user-avatar-btn');
@@ -139,7 +143,8 @@
removeWatermark();
updateUserDisplay(null);
userDropdown.style.display = 'none';
- location.hash = '#/login';
+ // 重定向到独立登录页面
+ window.location.href = './login.html';
})
.catch(() => {});
});
diff --git a/frontend/js/router.js b/frontend/js/router.js
index d9ab0db..344d35a 100644
--- a/frontend/js/router.js
+++ b/frontend/js/router.js
@@ -33,18 +33,7 @@ const Router = (() => {
updateBreadcrumb(path);
});
- // 检查用户登录状态,决定默认路径
- let defaultPath = '/dashboard';
- try {
- const user = await API.me().catch(() => null);
- if (!user || !user.username) {
- defaultPath = '/login';
- }
- } catch(e) {
- defaultPath = '/login';
- }
-
- const path = location.hash.replace('#', '') || defaultPath;
+ const path = location.hash.replace('#', '') || '/dashboard';
navigate(path);
highlightActive(path);
updateBreadcrumb(path);
@@ -94,6 +83,8 @@ const Router = (() => {
production: '时间记录',
'production-mgmt': '生产管理',
'work-order': '生产工单下发中心',
+ 'plan-mgmt': '计划管理',
+ 'material-purchase': '物料清单-采购',
export: '导出',
settings: '设置'
};
diff --git a/server/app.py b/server/app.py
index c65b3d5..2f1ba2f 100644
--- a/server/app.py
+++ b/server/app.py
@@ -157,6 +157,33 @@ def init_db():
created_at TEXT,
updated_at TEXT
)''')
+ c.execute('''CREATE TABLE IF NOT EXISTS material_purchase(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ list_no TEXT NOT NULL,
+ plan_no TEXT NOT NULL,
+ bom_result TEXT,
+ status TEXT NOT NULL,
+ demand_status TEXT NOT NULL,
+ complete_rate REAL,
+ material_code TEXT NOT NULL,
+ material_name TEXT NOT NULL,
+ batch_no TEXT,
+ level INTEGER,
+ required_qty INTEGER NOT NULL,
+ stock_qty INTEGER,
+ shortage INTEGER,
+ acquire_method TEXT,
+ realtime_stock INTEGER,
+ pending_qty INTEGER,
+ dispatched_qty INTEGER,
+ received_qty INTEGER,
+ submitter TEXT,
+ submit_time TEXT,
+ update_time TEXT,
+ deleted INTEGER DEFAULT 0,
+ deleted_at TEXT
+ )''')
# 为已存在的表添加列(如果不存在)
try:
c.execute('ALTER TABLE work_orders ADD COLUMN product_model TEXT')
@@ -412,19 +439,102 @@ def index():
return send_from_directory(FRONTEND_DIR, 'index.html')
+@app.route('/index.html')
+def index_html():
+ return send_from_directory(FRONTEND_DIR, 'index.html')
+
+
# auth
+@app.get('/api/auth/captcha')
+def captcha():
+ """生成验证码图片"""
+ try:
+ from PIL import Image, ImageDraw, ImageFont
+ import random
+ import io
+ import base64
+
+ # 生成4位随机数字
+ code = ''.join([str(random.randint(0, 9)) for _ in range(4)])
+
+ # 将验证码存储到session
+ session['captcha'] = code
+
+ # 创建图片
+ width, height = 120, 40
+ image = Image.new('RGB', (width, height), color='#f0f4f8')
+ draw = ImageDraw.Draw(image)
+
+ # 尝试使用系统字体,如果失败则使用默认字体
+ try:
+ font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 28)
+ except:
+ try:
+ font = ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc', 28)
+ except:
+ font = ImageFont.load_default()
+
+ # 绘制干扰线
+ for _ in range(3):
+ x1 = random.randint(0, width)
+ y1 = random.randint(0, height)
+ x2 = random.randint(0, width)
+ y2 = random.randint(0, height)
+ draw.line([(x1, y1), (x2, y2)], fill='#cbd5e1', width=1)
+
+ # 绘制验证码文字
+ colors = ['#3b82f6', '#2563eb', '#1e40af', '#1e3a8a']
+ for i, char in enumerate(code):
+ x = 20 + i * 25 + random.randint(-3, 3)
+ y = 5 + random.randint(-3, 3)
+ color = random.choice(colors)
+ draw.text((x, y), char, font=font, fill=color)
+
+ # 绘制干扰点
+ for _ in range(50):
+ x = random.randint(0, width)
+ y = random.randint(0, height)
+ draw.point((x, y), fill='#94a3b8')
+
+ # 转换为base64
+ buffer = io.BytesIO()
+ image.save(buffer, format='PNG')
+ buffer.seek(0)
+ img_base64 = base64.b64encode(buffer.getvalue()).decode()
+
+ return jsonify({'image': f'data:image/png;base64,{img_base64}'})
+ except Exception as e:
+ log('captcha_error', str(e))
+ # 如果生成失败,返回简单的验证码
+ code = ''.join([str(random.randint(0, 9)) for _ in range(4)])
+ session['captcha'] = code
+ return jsonify({'image': '', 'code': code})
+
+
@app.post('/api/auth/login')
def login():
data = request.get_json() or {}
username = data.get('username')
password = data.get('password')
+ captcha = data.get('captcha')
+
+ # 验证验证码
+ session_captcha = session.get('captcha', '').lower()
+ if not captcha or captcha.lower() != session_captcha:
+ # 清除验证码,防止重复使用
+ session.pop('captcha', None)
+ return jsonify({'error': '验证码错误'}), 400
+
+ # 清除验证码,防止重复使用
+ session.pop('captcha', None)
+
conn = get_db()
c = conn.cursor()
c.execute('SELECT id, password_hash, role FROM users WHERE username=?', (username,))
row = c.fetchone()
conn.close()
if not row or not check_password_hash(row['password_hash'], password or ''):
- return jsonify({'error': 'invalid credentials'}), 400
+ return jsonify({'error': '用户名或密码错误'}), 400
session['user_id'] = row['id']
session['role'] = row['role']
session['username'] = username
@@ -2822,6 +2932,429 @@ def confirm_work_order(order_id):
return jsonify({'ok': True, 'message': '工单确认成功'})
+# ==================== 物料清单-采购 API ====================
+
+def convert_material_purchase_to_camel(row):
+ """将数据库行转换为驼峰命名的字典"""
+ return {
+ 'id': row['id'],
+ 'title': row['title'],
+ 'listNo': row['list_no'],
+ 'planNo': row['plan_no'],
+ 'bomResult': row['bom_result'],
+ 'status': row['status'],
+ 'demandStatus': row['demand_status'],
+ 'completeRate': row['complete_rate'],
+ 'materialCode': row['material_code'],
+ 'materialName': row['material_name'],
+ 'batchNo': row['batch_no'],
+ 'level': row['level'],
+ 'requiredQty': row['required_qty'],
+ 'stockQty': row['stock_qty'],
+ 'shortage': row['shortage'],
+ 'acquireMethod': row['acquire_method'],
+ 'realtimeStock': row['realtime_stock'],
+ 'pendingQty': row['pending_qty'],
+ 'dispatchedQty': row['dispatched_qty'],
+ 'receivedQty': row['received_qty'],
+ 'submitter': row['submitter'],
+ 'submitTime': row['submit_time'],
+ 'updateTime': row['update_time'],
+ 'deleted': row['deleted'],
+ 'deletedAt': row['deleted_at']
+ }
+
+@app.get('/api/material-purchase/list')
+@require_login
+def list_material_purchase():
+ """获取物料清单列表(不包括已删除的)"""
+ conn = get_db()
+ c = conn.cursor()
+ c.execute('''SELECT * FROM material_purchase
+ WHERE deleted=0
+ ORDER BY submit_time DESC''')
+ rows = c.fetchall()
+ conn.close()
+ return jsonify({'list': [convert_material_purchase_to_camel(r) for r in rows]})
+
+
+@app.get('/api/material-purchase/recycle-bin')
+@require_login
+def list_material_purchase_recycle_bin():
+ """获取回收站列表"""
+ conn = get_db()
+ c = conn.cursor()
+ c.execute('''SELECT * FROM material_purchase
+ WHERE deleted=1
+ ORDER BY deleted_at DESC''')
+ rows = c.fetchall()
+ conn.close()
+ return jsonify({'list': [convert_material_purchase_to_camel(r) for r in rows]})
+
+
+@app.post('/api/material-purchase/add')
+@require_login
+@require_any_role('admin', 'superadmin')
+def add_material_purchase():
+ """新增物料需求"""
+ data = request.get_json() or {}
+
+ required_fields = ['title', 'list_no', 'plan_no', 'status', 'demand_status',
+ 'material_code', 'material_name', 'required_qty']
+ for field in required_fields:
+ if not data.get(field):
+ return jsonify({'error': f'缺少必填字段: {field}'}), 400
+
+ conn = get_db()
+ c = conn.cursor()
+ now = get_beijing_time()
+ username = session.get('username', '')
+
+ c.execute('''INSERT INTO material_purchase(
+ title, list_no, plan_no, bom_result, status, demand_status,
+ complete_rate, material_code, material_name, batch_no, level,
+ required_qty, stock_qty, shortage, acquire_method, realtime_stock,
+ pending_qty, dispatched_qty, received_qty, submitter, submit_time, update_time
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (
+ data.get('title'), data.get('list_no'), data.get('plan_no'),
+ data.get('bom_result'), data.get('status'), data.get('demand_status'),
+ data.get('complete_rate'), data.get('material_code'), data.get('material_name'),
+ data.get('batch_no'), data.get('level'), data.get('required_qty'),
+ data.get('stock_qty'), data.get('shortage'), data.get('acquire_method'),
+ data.get('realtime_stock'), data.get('pending_qty'), data.get('dispatched_qty'),
+ data.get('received_qty'), username, now, now
+ ))
+
+ conn.commit()
+ item_id = c.lastrowid
+ conn.close()
+
+ log('add_material_purchase', f'物料编码: {data.get("material_code")}')
+ return jsonify({'ok': True, 'id': item_id})
+
+
+@app.post('/api/material-purchase/update')
+@require_login
+@require_any_role('admin', 'superadmin')
+def update_material_purchase():
+ """更新物料需求"""
+ data = request.get_json() or {}
+ item_id = data.get('id')
+
+ if not item_id:
+ return jsonify({'error': '缺少ID'}), 400
+
+ conn = get_db()
+ c = conn.cursor()
+ now = get_beijing_time()
+
+ c.execute('''UPDATE material_purchase SET
+ title=?, list_no=?, plan_no=?, bom_result=?, status=?, demand_status=?,
+ complete_rate=?, material_code=?, material_name=?, batch_no=?, level=?,
+ required_qty=?, stock_qty=?, shortage=?, acquire_method=?, realtime_stock=?,
+ pending_qty=?, dispatched_qty=?, received_qty=?, update_time=?
+ WHERE id=?''', (
+ data.get('title'), data.get('list_no'), data.get('plan_no'),
+ data.get('bom_result'), data.get('status'), data.get('demand_status'),
+ data.get('complete_rate'), data.get('material_code'), data.get('material_name'),
+ data.get('batch_no'), data.get('level'), data.get('required_qty'),
+ data.get('stock_qty'), data.get('shortage'), data.get('acquire_method'),
+ data.get('realtime_stock'), data.get('pending_qty'), data.get('dispatched_qty'),
+ data.get('received_qty'), now, item_id
+ ))
+
+ conn.commit()
+ conn.close()
+
+ log('update_material_purchase', f'ID: {item_id}')
+ return jsonify({'ok': True})
+
+
+@app.post('/api/material-purchase/delete')
+@require_login
+@require_any_role('admin', 'superadmin')
+def delete_material_purchase():
+ """删除物料需求(移到回收站)"""
+ data = request.get_json() or {}
+ item_ids = data.get('ids', [])
+
+ if not item_ids:
+ return jsonify({'error': '缺少ID'}), 400
+
+ conn = get_db()
+ c = conn.cursor()
+ now = get_beijing_time()
+
+ placeholders = ','.join('?' * len(item_ids))
+ c.execute(f'''UPDATE material_purchase SET
+ deleted=1, deleted_at=?
+ WHERE id IN ({placeholders})''', [now] + item_ids)
+
+ conn.commit()
+ conn.close()
+
+ log('delete_material_purchase', f'删除数量: {len(item_ids)}')
+ return jsonify({'ok': True, 'count': len(item_ids)})
+
+
+@app.post('/api/material-purchase/restore')
+@require_login
+@require_any_role('admin', 'superadmin')
+def restore_material_purchase():
+ """从回收站恢复"""
+ data = request.get_json() or {}
+ item_id = data.get('id')
+
+ if not item_id:
+ return jsonify({'error': '缺少ID'}), 400
+
+ conn = get_db()
+ c = conn.cursor()
+
+ c.execute('''UPDATE material_purchase SET
+ deleted=0, deleted_at=NULL
+ WHERE id=?''', (item_id,))
+
+ conn.commit()
+ conn.close()
+
+ log('restore_material_purchase', f'ID: {item_id}')
+ return jsonify({'ok': True})
+
+
+@app.post('/api/material-purchase/permanent-delete')
+@require_login
+@require_any_role('admin', 'superadmin')
+def permanent_delete_material_purchase():
+ """永久删除"""
+ data = request.get_json() or {}
+ item_id = data.get('id')
+
+ if not item_id:
+ return jsonify({'error': '缺少ID'}), 400
+
+ conn = get_db()
+ c = conn.cursor()
+
+ c.execute('DELETE FROM material_purchase WHERE id=?', (item_id,))
+
+ conn.commit()
+ conn.close()
+
+ log('permanent_delete_material_purchase', f'ID: {item_id}')
+ return jsonify({'ok': True})
+
+
+@app.post('/api/material-purchase/empty-recycle-bin')
+@require_login
+@require_any_role('admin', 'superadmin')
+def empty_recycle_bin():
+ """清空回收站"""
+ conn = get_db()
+ c = conn.cursor()
+
+ c.execute('DELETE FROM material_purchase WHERE deleted=1')
+ count = c.rowcount
+
+ conn.commit()
+ conn.close()
+
+ log('empty_recycle_bin', f'清空数量: {count}')
+ return jsonify({'ok': True, 'count': count})
+
+
+@app.route('/api/validate/material-purchase-file', methods=['POST'])
+@require_login
+@require_any_role('admin', 'superadmin')
+def validate_material_purchase_file():
+ """验证物料清单Excel文件格式"""
+ f = request.files.get('file')
+ if not f:
+ return jsonify({'error': 'no file'}), 400
+
+ name = secure_filename(f.filename or '')
+ ext = (name.split('.')[-1] or '').lower()
+
+ if ext not in ['xlsx', 'xls']:
+ return jsonify({'valid': False, 'message': '文件格式不支持,请上传Excel文件(.xlsx或.xls)'}), 200
+
+ try:
+ import openpyxl
+ wb = openpyxl.load_workbook(f)
+ ws = wb.active
+
+ if ws.max_row < 2:
+ wb.close()
+ return jsonify({'valid': False, 'message': '文件为空,没有数据'}), 200
+
+ # 检查表头
+ header_row = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
+ header = [str(h).strip() if h else '' for h in header_row]
+
+ # 必需的列
+ required_columns = ['标题', '生产计划明细物料需求清单编号', '生产计划编号', '状态',
+ '需求状态', '物料编码', '物料名称', '所需物料数']
+
+ missing_columns = []
+ for col in required_columns:
+ if col not in header:
+ missing_columns.append(col)
+
+ if missing_columns:
+ wb.close()
+ return jsonify({
+ 'valid': False,
+ 'message': f'缺少必需的列:{", ".join(missing_columns)}'
+ }), 200
+
+ data_rows = ws.max_row - 1
+ wb.close()
+ return jsonify({
+ 'valid': True,
+ 'message': f'文件格式正确,共{data_rows}行数据'
+ }), 200
+
+ except Exception as e:
+ return jsonify({'valid': False, 'message': f'读取文件失败:{str(e)}'}), 200
+
+
+@app.post('/api/upload/material-purchase-file')
+@require_login
+@require_any_role('admin', 'superadmin')
+def upload_material_purchase_file():
+ """上传物料清单Excel文件"""
+ f = request.files.get('file')
+ if not f:
+ return jsonify({'error': 'no file'}), 400
+
+ try:
+ import openpyxl
+ wb = openpyxl.load_workbook(f)
+ ws = wb.active
+
+ # 获取表头
+ header_row = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
+ header = [str(h).strip() if h else '' for h in header_row]
+
+ # 创建列索引映射
+ col_map = {h: i for i, h in enumerate(header)}
+
+ # 辅助函数:解析百分比字符串
+ def parse_percentage(value):
+ if value is None:
+ return 0
+ if isinstance(value, (int, float)):
+ return float(value)
+ # 如果是字符串,去除%符号
+ value_str = str(value).strip()
+ if value_str.endswith('%'):
+ value_str = value_str[:-1]
+ try:
+ return float(value_str)
+ except:
+ return 0
+
+ # 辅助函数:安全转换为整数
+ def safe_int(value, default=0):
+ if value is None:
+ return default
+ try:
+ return int(float(value))
+ except:
+ return default
+
+ # 读取数据
+ rows = []
+ for row in ws.iter_rows(min_row=2, values_only=True):
+ if not any(row): # 跳过空行
+ continue
+
+ # 辅助函数:安全获取单元格值
+ def get_cell_value(col_name, default=''):
+ if col_name not in col_map:
+ return default
+ idx = col_map[col_name]
+ if idx >= len(row):
+ return default
+ value = row[idx]
+ return value if value is not None else default
+
+ # 提取数据
+ item = {
+ 'title': str(get_cell_value('标题') or ''),
+ 'list_no': str(get_cell_value('生产计划明细物料需求清单编号') or ''),
+ 'plan_no': str(get_cell_value('生产计划编号') or ''),
+ 'bom_result': str(get_cell_value('产品BOM分析结果') or ''),
+ 'status': str(get_cell_value('状态') or 'pending'),
+ 'demand_status': str(get_cell_value('需求状态') or 'normal'),
+ 'complete_rate': parse_percentage(get_cell_value('物料齐套率', 0)),
+ 'material_code': str(get_cell_value('物料编码') or ''),
+ 'material_name': str(get_cell_value('物料名称') or ''),
+ 'batch_no': str(get_cell_value('物料批次号') or ''),
+ 'level': safe_int(get_cell_value('物料层级', 1), 1),
+ 'required_qty': safe_int(get_cell_value('所需物料数', 0), 0),
+ 'stock_qty': safe_int(get_cell_value('库存现有物料数', 0), 0),
+ 'shortage': safe_int(get_cell_value('欠缺值', 0), 0),
+ 'acquire_method': str(get_cell_value('物料获取方式') or ''),
+ 'realtime_stock': safe_int(get_cell_value('实时库存值', 0), 0),
+ 'pending_qty': safe_int(get_cell_value('待入库数量', 0), 0),
+ 'dispatched_qty': safe_int(get_cell_value('派发数量', 0), 0),
+ 'received_qty': safe_int(get_cell_value('入库数量', 0), 0),
+ }
+
+ # 验证必填字段
+ if not item['title'] or not item['list_no'] or not item['material_code']:
+ continue
+
+ rows.append(item)
+
+ wb.close()
+
+ if not rows:
+ return jsonify({'error': '文件中没有有效数据'}), 400
+
+ # 批量插入数据库
+ conn = get_db()
+ c = conn.cursor()
+ now = get_beijing_time()
+ username = session.get('username', '')
+
+ for item in rows:
+ c.execute('''INSERT INTO material_purchase(
+ title, list_no, plan_no, bom_result, status, demand_status,
+ complete_rate, material_code, material_name, batch_no, level,
+ required_qty, stock_qty, shortage, acquire_method, realtime_stock,
+ pending_qty, dispatched_qty, received_qty, submitter, submit_time, update_time
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (
+ item['title'], item['list_no'], item['plan_no'], item['bom_result'],
+ item['status'], item['demand_status'], item['complete_rate'],
+ item['material_code'], item['material_name'], item['batch_no'],
+ item['level'], item['required_qty'], item['stock_qty'], item['shortage'],
+ item['acquire_method'], item['realtime_stock'], item['pending_qty'],
+ item['dispatched_qty'], item['received_qty'], username, now, now
+ ))
+
+ conn.commit()
+ conn.close()
+
+ log('upload_material_purchase_file', f'导入数量: {len(rows)}')
+ return jsonify({'ok': True, 'count': len(rows), 'message': f'成功导入{len(rows)}条数据'})
+
+ except Exception as e:
+ return jsonify({'error': f'导入失败:{str(e)}'}), 500
+
+
+@app.errorhandler(404)
+def not_found(e):
+ # 如果请求的是 HTML 页面(通过 Accept header 判断),返回 index.html
+ # 这样可以支持前端路由的直接访问
+ if request.path and not request.path.startswith('/api/'):
+ accept = request.headers.get('Accept', '')
+ if 'text/html' in accept:
+ return send_from_directory(FRONTEND_DIR, 'index.html')
+ return jsonify({'error': 'not found'}), 404
+
+
init_db()
diff --git a/用户.svg b/用户.svg
deleted file mode 100644
index 6834ce2..0000000
--- a/用户.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file