"""用户管理路由""" import csv, io, base64, binascii, uuid from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template from services.db import query from services.passwords import hash_password, validate_password from routes.auth import login_required, role_required users_bp = Blueprint('users', __name__) DEPARTMENTS = ('天猫', '京东', '天猫国际', '京东国际', '阿里', '新媒体', '拼多多', 'AI指挥部', '采购部', '产品部') def _normalize_department(value): department = str(value or '').strip() if department and department not in DEPARTMENTS: raise ValueError('部门必须为:' + '、'.join(DEPARTMENTS)) return department @users_bp.route('/api/users/positions') @login_required def position_list(): """获取启用的岗位列表,供用户管理中的岗位选择和筛选使用。""" rows = query( "SELECT position_code AS code, position_name AS name " "FROM user_positions WHERE status=1 ORDER BY sort_order ASC, id ASC" ) return jsonify({"code": 0, "data": rows}) @users_bp.route('/api/users/positions/create', methods=['POST']) @login_required def position_create(): """新增岗位。岗位编码由系统生成,避免中文岗位名影响现有业务匹配。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可新增岗位"}) data = request.get_json(silent=True) or {} name = str(data.get('name', '')).strip() if not name: return jsonify({"code": 400, "msg": "岗位名称不能为空"}) if len(name) > 100: return jsonify({"code": 400, "msg": "岗位名称不能超过100个字符"}) try: exists = query( "SELECT id FROM user_positions WHERE position_name=%s LIMIT 1", (name,) ) if exists: return jsonify({"code": 400, "msg": "该岗位已存在"}) position_code = 'custom_' + uuid.uuid4().hex[:16] query( "INSERT INTO user_positions (position_code, position_name, sort_order, status) " "VALUES (%s, %s, 0, 1)", (position_code, name) ) return jsonify({ "code": 0, "msg": "岗位新增成功", "data": {"code": position_code, "name": name} }) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @users_bp.route('/users') @login_required def users_page(): if not role_required('users'): return redirect(url_for('auth.index')) return render_template('base.html', page='users') # ─── API: 用户管理 ─── @users_bp.route('/api/users/list') @login_required def user_list(): search = request.args.get('search', '').strip() role = request.args.get('role', '').strip() position = request.args.get('position', '').strip() status = request.args.get('status', '').strip() sort = request.args.get('sort', '').strip() sql = "SELECT id, username, real_name, role, user_level, position, department, phone, email, status, last_login, created_at, is_protected FROM dashboard_users WHERE 1=1" params = [] if search: sql += " AND (real_name LIKE %s OR username LIKE %s OR position LIKE %s OR department LIKE %s)" params.extend(['%' + search + '%'] * 4) if role: sql += " AND role = %s" params.append(role) if position: sql += " AND position = %s" params.append(position) department = request.args.get('department', '').strip() if department: sql += " AND department = %s" params.append(department) if status: sql += " AND status = %s" params.append(int(status)) if sort == 'role_asc': sql += " ORDER BY role ASC, id ASC" elif sort == 'role_desc': sql += " ORDER BY role DESC, id ASC" else: sql += " ORDER BY CASE WHEN role IN ('super_admin','admin') THEN 0 ELSE 1 END, FIELD(role,'super_admin','admin'), id ASC" users = query(sql, tuple(params) if params else None) return jsonify({"code": 0, "data": users}) @users_bp.route('/api/users/create', methods=['POST']) @login_required def user_create(): if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.get_json(silent=True) or {} valid, message = validate_password(data.get('password', '')) if not valid: return jsonify({"code": 400, "msg": message}) try: department = _normalize_department(data.get('department')) except ValueError as exc: return jsonify({"code": 400, "msg": str(exc)}) try: query(""" INSERT INTO dashboard_users (username, password, real_name, role, user_level, position, department, phone, email, must_change_password) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 1) """, ( data['username'], hash_password(data['password']), data.get('real_name', ''), data.get('role', 'viewer'), data.get('user_level', 'employee'), data.get('position', ''), department, data.get('phone', ''), data.get('email', '') )) return jsonify({"code": 0, "msg": "创建成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @users_bp.route('/api/users/update', methods=['POST']) @login_required def user_update(): if session.get('role') != 'super_admin': return jsonify({"code": 403, "msg": "权限不足,仅超级管理员可以编辑用户"}) data = request.json user_id = data['id'] try: department = _normalize_department(data.get('department')) except ValueError as exc: return jsonify({"code": 400, "msg": str(exc)}) if data.get('password'): valid, message = validate_password(data['password']) if not valid: return jsonify({"code": 400, "msg": message}) query(""" UPDATE dashboard_users SET real_name=%s, role=%s, user_level=%s, position=%s, department=%s, phone=%s, email=%s, status=%s, password=%s, must_change_password=1 WHERE id=%s """, ( data.get('real_name', ''), data.get('role', 'viewer'), data.get('user_level', 'employee'), data.get('position', ''), department, data.get('phone', ''), data.get('email', ''), int(data.get('status', 1)), hash_password(data['password']), user_id )) else: query(""" UPDATE dashboard_users SET real_name=%s, role=%s, user_level=%s, position=%s, department=%s, phone=%s, email=%s, status=%s WHERE id=%s """, ( data.get('real_name', ''), data.get('role', 'viewer'), data.get('user_level', 'employee'), data.get('position', ''), department, data.get('phone', ''), data.get('email', ''), int(data.get('status', 1)), user_id )) return jsonify({"code": 0, "msg": "更新成功"}) @users_bp.route('/api/users/delete', methods=['POST']) @login_required def user_delete(): if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) user_id = request.json.get('id') if int(user_id) == session['user_id']: return jsonify({"code": 500, "msg": "不能删除自己"}) protected = query("SELECT is_protected FROM dashboard_users WHERE id=%s", (user_id,)) if protected and protected[0].get('is_protected') == 1: return jsonify({"code": 500, "msg": "该用户为受保护账号,不可删除"}) query("DELETE FROM dashboard_users WHERE id=%s", (user_id,)) return jsonify({"code": 0, "msg": "删除成功"}) @users_bp.route('/api/users/template') @login_required def user_template(): """下载用户导入CSV模板""" csv_content = '\ufeff用户名,密码,真实姓名,角色,员工级别,岗位,部门,手机,邮箱,状态\n' csv_content += 'zhangsan,Abc123!@#45,张三,procurement_planner,employee,采购专员,天猫,13800138000,zhangsan@example.com,1\n' csv_content += 'lisi,Xyz789$%^01,李四,admin,employee,系统管理员,京东,13900139000,lisi@example.com,1\n' from urllib.parse import quote filename = '用户导入模板.csv' encoded = quote(filename, safe='') return csv_content, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': f"attachment; filename*=UTF-8''{encoded}" } def _validate_user_import_headers(headers): normalized = [(str(header).strip() if header is not None else '') for header in headers] if not {'用户名', '密码'}.issubset(normalized): raise ValueError("导入文件缺少“用户名”或“密码”表头,请使用系统下载的用户导入模板") return normalized def _excel_cell_text(value): """将 Excel 单元格转换为稳定文本,避免手机号等整数出现 .0。""" if value is None: return '' if isinstance(value, bool): return '1' if value else '0' if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(value).strip() def _rows_from_excel_values(values): iterator = iter(values) try: headers = _validate_user_import_headers(next(iterator)) except StopIteration: raise ValueError("导入文件为空") rows = [] for values_row in iterator: cells = [_excel_cell_text(value) for value in values_row] if not any(cells): continue rows.append({ header: (cells[index] if index < len(cells) else '') for index, header in enumerate(headers) if header }) return rows def _parse_user_import_csv(raw_bytes): """解码并完整解析用户 CSV,兼容 Excel 导出的 CR/LF/CRLF 换行。""" parse_errors = [] for encoding in ('utf-8-sig', 'gb18030', 'gbk', 'utf-8'): try: decoded = raw_bytes.decode(encoding) except UnicodeDecodeError: continue try: # newline='' 让 csv 模块自行识别 CR、LF 和 CRLF。StringIO 默认的 # newline='\n' 会保留单独的 CR,随后触发 # "new-line character seen in unquoted field"。 reader = csv.DictReader(io.StringIO(decoded, newline=''), strict=True) headers = _validate_user_import_headers(reader.fieldnames or []) reader.fieldnames = headers return [row for row in reader if any(str(value or '').strip() for value in row.values())] except csv.Error as exc: parse_errors.append(str(exc)) except ValueError: continue if parse_errors: raise ValueError(f"CSV格式错误:{parse_errors[-1]}") raise ValueError("无法识别CSV编码或表头,请使用系统下载的用户导入模板") def _parse_user_import_xlsx(raw_bytes): try: from openpyxl import load_workbook workbook = load_workbook(io.BytesIO(raw_bytes), read_only=True, data_only=True) except Exception as exc: raise ValueError(f"XLSX文件无法读取:{exc}") try: if not workbook.sheetnames: raise ValueError("XLSX文件不包含工作表") return _rows_from_excel_values(workbook.active.iter_rows(values_only=True)) finally: workbook.close() def _parse_user_import_xls(raw_bytes): try: import xlrd except ImportError: raise ValueError("服务器缺少XLS解析组件,请联系管理员") try: workbook = xlrd.open_workbook(file_contents=raw_bytes) if workbook.nsheets == 0: raise ValueError("XLS文件不包含工作表") sheet = workbook.sheet_by_index(0) return _rows_from_excel_values(sheet.row_values(index) for index in range(sheet.nrows)) except ValueError: raise except Exception as exc: raise ValueError(f"XLS文件无法读取:{exc}") def _parse_user_import_file(raw_bytes, filename): extension = (filename or 'users.csv').rsplit('.', 1)[-1].lower() if extension == 'csv': return _parse_user_import_csv(raw_bytes) if extension == 'xlsx': return _parse_user_import_xlsx(raw_bytes) if extension == 'xls': return _parse_user_import_xls(raw_bytes) raise ValueError("仅支持CSV、XLSX、XLS格式的用户导入文件") @users_bp.route('/api/users/import', methods=['POST']) @login_required def user_import(): """导入 CSV、XLSX 或 XLS 用户文件。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.get_json(silent=True) or {} encoded_file = data.get('file_base64') or data.get('csv_base64') filename = str(data.get('filename') or 'users.csv').strip() if not encoded_file: return jsonify({"code": 400, "msg": "请选择需要导入的用户文件"}) try: raw_bytes = base64.b64decode(encoded_file, validate=True) except (binascii.Error, ValueError, TypeError): return jsonify({"code": 400, "msg": "文件传输内容无效,请重新选择文件"}) try: rows = _parse_user_import_file(raw_bytes, filename) except ValueError as exc: return jsonify({"code": 400, "msg": str(exc)}) success = 0 errors = [] for i, row in enumerate(rows, start=2): username = (row.get('用户名') or '').strip() password = (row.get('密码') or '').strip() if not username or not password: errors.append(f"第{i}行:用户名和密码不能为空") continue valid, message = validate_password(password) if not valid: errors.append(f"第{i}行:{message}") continue try: department = _normalize_department(row.get('部门')) except ValueError as exc: errors.append(f"第{i}行:{exc}") continue try: query( """INSERT INTO dashboard_users (username, password, real_name, role, user_level, position, department, phone, email, status, must_change_password) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1)""", ( username, hash_password(password), (row.get('真实姓名') or '').strip(), (row.get('角色') or 'viewer').strip(), (row.get('员工级别') or 'employee').strip(), (row.get('岗位') or '').strip(), department, (row.get('手机') or '').strip(), (row.get('邮箱') or '').strip(), int((row.get('状态') or '1').strip()) ) ) success += 1 except Exception as e: errors.append(f"第{i}行({username}):{str(e)}") return jsonify({ "code": 0, "data": {"success": success, "errors": errors}, "msg": f"成功导入{success}个用户" + (f",{len(errors)}个失败" if errors else "") }) @users_bp.route('/api/users/followers') @login_required def user_followers(): """获取采购跟单人员列表""" rows = query("SELECT real_name FROM dashboard_users WHERE position='procurement_follower' AND status=1 ORDER BY real_name") return jsonify({"code": 0, "data": [r['real_name'] for r in rows]})