dy-dashboard/routes/users.py
2026-10-28 11:14:17 +08:00

403 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""用户管理路由"""
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]})