3280 lines
127 KiB
PHP
3280 lines
127 KiB
PHP
<?php
|
||
namespace app\controllers;
|
||
|
||
use core\base\Controller;
|
||
use app\models\Employee;
|
||
use app\models\Workstation;
|
||
use app\models\Customer;
|
||
use app\models\ProductModel;
|
||
use app\models\SysConfig;
|
||
use app\models\EmployeePermission;
|
||
use app\models\ProductTypeConfig;
|
||
use app\models\StationDefinition;
|
||
|
||
class AdminController extends Controller
|
||
{
|
||
public function index()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// 只有超级管理员 或 MES类目的管理员可访问
|
||
if ($user['role'] === self::ROLE_SUPER_ADMIN) {
|
||
// 超级管理员可以访问
|
||
} elseif ($user['role'] === self::ROLE_ADMIN && $user['category'] === self::CATEGORY_MES) {
|
||
// MES 类目管理员可以访问
|
||
} else {
|
||
header('Location: ' . BASE_URL . '/Front/index');
|
||
exit;
|
||
}
|
||
|
||
$config = new SysConfig();
|
||
$sysConfig = $config->getConfig();
|
||
|
||
// 统计数据
|
||
$employee = new Employee();
|
||
$employeeCount = count($employee->getAll());
|
||
|
||
$customer = new Customer();
|
||
$customerCount = count($customer->getAll());
|
||
|
||
$productModel = new ProductModel();
|
||
$modelCount = count($productModel->getAll());
|
||
|
||
$workstation = new Workstation();
|
||
$stationCount = count($workstation->getAll());
|
||
|
||
$this->assign('title', '后台管理');
|
||
$this->assign('user', $user);
|
||
$this->assign('sysConfig', $sysConfig);
|
||
$this->assign('employeeCount', $employeeCount);
|
||
$this->assign('customerCount', $customerCount);
|
||
$this->assign('modelCount', $modelCount);
|
||
$this->assign('stationCount', $stationCount);
|
||
$this->render();
|
||
}
|
||
|
||
// 员工管理
|
||
public function employee()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// 仅超级管理员可访问员工管理
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$employee = new Employee();
|
||
$employees = $employee->getAll();
|
||
|
||
// 管理员看不到超级管理员(比自己权限高的不可见)
|
||
if ($user['role'] === self::ROLE_ADMIN) {
|
||
$employees = array_filter($employees, function($emp) {
|
||
return $emp['role'] !== self::ROLE_SUPER_ADMIN;
|
||
});
|
||
$employees = array_values($employees); // re-index
|
||
}
|
||
|
||
// Flash 消息(一次性提示,如密码生成提示)
|
||
$flash = $_SESSION['flash_message'] ?? null;
|
||
if ($flash) {
|
||
unset($_SESSION['flash_message']);
|
||
}
|
||
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
|
||
$this->assign('title', '员工管理');
|
||
$this->assign('user', $user);
|
||
$this->assign('employees', $employees);
|
||
$this->assign('flash', $flash);
|
||
$this->render();
|
||
}
|
||
|
||
public function employeeAdd()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// 只有超级管理员可以添加员工(管理员由超管分配类目)
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
|
||
// 输入验证
|
||
$empName = trim($_POST['emp_name'] ?? '');
|
||
if (empty($empName) || mb_strlen($empName) > 50) {
|
||
exit('员工姓名无效');
|
||
}
|
||
|
||
$role = $_POST['role'];
|
||
$category = $_POST['category'] ?? self::CATEGORY_MES;
|
||
|
||
// 管理员和操作员必须分配类目
|
||
if ($role !== self::ROLE_SUPER_ADMIN && empty($category)) {
|
||
exit('请为管理员/操作员选择管理类目');
|
||
}
|
||
|
||
// 自动生成员工编号:ZXY + 三位流水号
|
||
$emp_no = $this->generateEmpNo();
|
||
|
||
$data = array(
|
||
'emp_no' => $emp_no,
|
||
'emp_name' => $empName,
|
||
'password' => $_POST['password'] ?? '',
|
||
'role' => $role,
|
||
'category' => ($role === self::ROLE_SUPER_ADMIN) ? self::CATEGORY_ALL : $category,
|
||
'created_by' => $user['emp_name']
|
||
);
|
||
|
||
try {
|
||
$employee = new Employee();
|
||
$id = $employee->addEmployee($data);
|
||
if (!$id) {
|
||
// 插入失败(如编号重复),抛出异常让 catch 处理
|
||
$dbErr = $employee->getError() ?: '未知数据库错误';
|
||
throw new \RuntimeException('添加员工失败:' . $dbErr);
|
||
}
|
||
$genPwd = $employee->getLastGeneratedPwd();
|
||
|
||
if ($genPwd) {
|
||
// 自动生成密码时,跳转到列表并显示密码提示
|
||
$_SESSION['flash_message'] = "员工 {$empName}({$emp_no})已创建,初始密码为:{$genPwd},请通知该员工首次登录后修改密码";
|
||
}
|
||
} catch (\RuntimeException $e) {
|
||
$this->assign('error', $e->getMessage());
|
||
$this->assign('title', '添加员工');
|
||
$this->assign('user', $user);
|
||
$this->assign('manageableRoles', self::getManageableRoles($user['role']));
|
||
$this->assign('nextEmpNo', $emp_no);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
header('Location: ' . BASE_URL . '/Admin/employee');
|
||
exit;
|
||
}
|
||
|
||
// 传递可选角色列表和类目列表到视图
|
||
$manageableRoles = self::getManageableRoles($user['role']);
|
||
$categoryOptions = self::getCategoryOptions();
|
||
|
||
// 预览下一个编号
|
||
$nextEmpNo = $this->generateEmpNo();
|
||
|
||
$this->assign('title', '添加员工');
|
||
$this->assign('user', $user);
|
||
$this->assign('manageableRoles', $manageableRoles);
|
||
$this->assign('categoryOptions', $categoryOptions);
|
||
$this->assign('nextEmpNo', $nextEmpNo);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function employeeEdit($id)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// 只有超级管理员可以编辑
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$employee = new Employee();
|
||
$emp = $employee->where(['id = :id'], [':id' => $id])->fetch();
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
try {
|
||
$newRole = $_POST['role'];
|
||
$newCategory = $_POST['category'] ?? self::CATEGORY_MES;
|
||
|
||
$data = array(
|
||
'emp_name' => $_POST['emp_name'],
|
||
'role' => $newRole,
|
||
'category' => ($newRole === self::ROLE_SUPER_ADMIN) ? self::CATEGORY_ALL : $newCategory
|
||
);
|
||
|
||
$employee->updateEmployee($id, $data);
|
||
header('Location: ' . BASE_URL . '/Admin/employee');
|
||
exit;
|
||
} catch (\Throwable $e) {
|
||
error_log('[employeeEdit] ' . $e->getMessage());
|
||
$this->assign('error', '更新失败:' . $e->getMessage());
|
||
$this->assign('title', '编辑员工');
|
||
$this->assign('user', $user);
|
||
$this->assign('emp', $emp);
|
||
$manageableRoles = self::getManageableRoles($user['role']);
|
||
$categoryOptions = self::getCategoryOptions();
|
||
$this->assign('manageableRoles', $manageableRoles);
|
||
$this->assign('categoryOptions', $categoryOptions);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
$manageableRoles = self::getManageableRoles($user['role']);
|
||
$categoryOptions = self::getCategoryOptions();
|
||
|
||
$this->assign('title', '编辑员工');
|
||
$this->assign('user', $user);
|
||
$this->assign('emp', $emp);
|
||
$this->assign('manageableRoles', $manageableRoles);
|
||
$this->assign('categoryOptions', $categoryOptions);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function employeeDelete($id)
|
||
{
|
||
$this->checkLogin();
|
||
$this->requireMethod('POST');
|
||
$this->csrfVerify();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// 只有超级管理员可以删除
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$employee = new Employee();
|
||
$emp = $employee->where(['id = :id'], [':id' => $id])->fetch();
|
||
|
||
// 不能删除自己
|
||
if ((int)$emp['id'] === (int)$user['id']) {
|
||
exit('不能删除自己的账号');
|
||
}
|
||
|
||
$employee->delete($id);
|
||
header('Location: ' . BASE_URL . '/Admin/employee');
|
||
}
|
||
|
||
public function employeeResetPwd($id)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// 只有超级管理员可以重置密码
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$employee = new Employee();
|
||
$emp = $employee->where(['id = :id'], [':id' => $id])->fetch();
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
try {
|
||
$new_pwd = $_POST['new_password'];
|
||
$employee = new Employee();
|
||
$employee->resetPassword($id, $new_pwd);
|
||
header('Location: ' . BASE_URL . '/Admin/employee');
|
||
exit;
|
||
} catch (\Throwable $e) {
|
||
error_log('[employeeResetPwd] ' . $e->getMessage());
|
||
$this->assign('error', '重置密码失败:' . $e->getMessage());
|
||
$this->assign('title', '重置密码');
|
||
$this->assign('user', $user);
|
||
$this->assign('emp', $emp);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
$this->assign('title', '重置密码');
|
||
$this->assign('user', $user);
|
||
$this->assign('emp', $emp);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
/**
|
||
* 设置员工工位权限
|
||
*/
|
||
public function employeePermission($id)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// 只有超级管理员可以设置工位权限
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$employee = new Employee();
|
||
$emp = $employee->where(['id = :id'], [':id' => $id])->fetch();
|
||
|
||
if (!$emp) {
|
||
exit('员工不存在');
|
||
}
|
||
|
||
$workstation = new Workstation();
|
||
$allStations = $workstation->getAll();
|
||
|
||
$permModel = new EmployeePermission();
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
|
||
$permissions = [];
|
||
// $_POST['allow'] 是勾选的工位类型数组
|
||
$allowedTypes = isset($_POST['allow']) ? (array)$_POST['allow'] : [];
|
||
|
||
foreach ($allStations as $station) {
|
||
$stype = $station['station_type'];
|
||
$permissions[$stype] = in_array($stype, $allowedTypes) ? 1 : 0;
|
||
}
|
||
|
||
$permModel->savePermissions($id, $permissions);
|
||
header('Location: ' . BASE_URL . '/Admin/employeePermission/' . $id . '?saved=1');
|
||
exit;
|
||
}
|
||
|
||
// 获取当前权限(表不存在时友好提示,而非 500)
|
||
try {
|
||
$currentPermissions = $permModel->getByEmployee($id);
|
||
} catch (\PDOException $e) {
|
||
exit('数据表 DGZXY_employee_station_permission 不存在,请在数据库中执行 sql/create_employee_station_permission.sql');
|
||
}
|
||
|
||
$saved = isset($_GET['saved']) && $_GET['saved'] === '1';
|
||
|
||
$this->assign('title', '工位权限设置 - ' . $emp['emp_name']);
|
||
$this->assign('user', $user);
|
||
$this->assign('emp', $emp);
|
||
$this->assign('stations', $allStations);
|
||
$this->assign('currentPermissions', $currentPermissions);
|
||
$this->assign('saved', $saved);
|
||
$this->assign('activeMenu', 'employee');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
// 客户管理 — 超级管理员和管理员
|
||
public function customer()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$customer = new Customer();
|
||
$customers = $customer->getAll();
|
||
|
||
$this->assign('title', '客户管理');
|
||
$this->assign('user', $user);
|
||
$this->assign('customers', $customers);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function customerAdd()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
try {
|
||
$data = array(
|
||
'company_name' => $_POST['company_name'],
|
||
'contact1' => $_POST['contact1'],
|
||
'phone1' => $_POST['phone1'],
|
||
'contact2' => $_POST['contact2'],
|
||
'phone2' => $_POST['phone2'],
|
||
'created_by' => $user['emp_name']
|
||
);
|
||
|
||
$customer = new Customer();
|
||
$customer->addCustomer($data);
|
||
header('Location: ' . BASE_URL . '/Admin/customer');
|
||
exit;
|
||
} catch (\Throwable $e) {
|
||
error_log('[customerAdd] ' . $e->getMessage());
|
||
$this->assign('error', '添加失败:' . $e->getMessage());
|
||
$this->assign('title', '添加客户');
|
||
$this->assign('user', $user);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
$this->assign('title', '添加客户');
|
||
$this->assign('user', $user);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function customerEdit($id)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$customer = new Customer();
|
||
$cust = $customer->where(['id = :id'], [':id' => $id])->fetch();
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
try {
|
||
$data = array(
|
||
'company_name' => $_POST['company_name'],
|
||
'contact1' => $_POST['contact1'],
|
||
'phone1' => $_POST['phone1'],
|
||
'contact2' => $_POST['contact2'],
|
||
'phone2' => $_POST['phone2']
|
||
);
|
||
|
||
$customer->updateCustomer($id, $data);
|
||
header('Location: ' . BASE_URL . '/Admin/customer');
|
||
exit;
|
||
} catch (\Throwable $e) {
|
||
error_log('[customerEdit] ' . $e->getMessage());
|
||
$this->assign('error', '更新失败:' . $e->getMessage());
|
||
$this->assign('title', '编辑客户');
|
||
$this->assign('user', $user);
|
||
$this->assign('cust', $cust);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
$this->assign('title', '编辑客户');
|
||
$this->assign('user', $user);
|
||
$this->assign('cust', $cust);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function customerDelete($id)
|
||
{
|
||
$this->checkLogin();
|
||
$this->requireMethod('POST');
|
||
$this->csrfVerify();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$customer = new Customer();
|
||
$customer->delete($id);
|
||
header('Location: ' . BASE_URL . '/Admin/customer');
|
||
}
|
||
|
||
// 产品型号管理 — 仅超级管理员
|
||
public function productModel()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$productModel = new ProductModel();
|
||
$allModels = $productModel->getAll();
|
||
|
||
// 按 type 分组
|
||
$grouped = [];
|
||
foreach ($allModels as $m) {
|
||
$t = $m['type'] ?: '未分类';
|
||
$grouped[$t][] = $m;
|
||
}
|
||
|
||
// 从数据库读取类型配置(排序),表不存在时回退到硬编码默认值
|
||
$typeConfigModel = new ProductTypeConfig();
|
||
$typeConfigs = $typeConfigModel->getAll();
|
||
if (empty($typeConfigs)) {
|
||
$typeConfigs = ProductTypeConfig::getDefaultConfigs();
|
||
}
|
||
$orderedTypeNames = [];
|
||
$typeColorMap = [];
|
||
foreach ($typeConfigs as $cfg) {
|
||
$orderedTypeNames[] = $cfg['type_name'];
|
||
$typeColorMap[$cfg['type_name']] = $cfg['color'] ?? 'default';
|
||
}
|
||
|
||
// 按配置的顺序排列,未配置的追加在后面
|
||
$ordered = [];
|
||
foreach ($orderedTypeNames as $t) {
|
||
if (isset($grouped[$t])) {
|
||
$ordered[$t] = $grouped[$t];
|
||
unset($grouped[$t]);
|
||
}
|
||
}
|
||
$ordered += $grouped; // 剩下的(包括"未分类")追加在后面
|
||
|
||
$this->assign('title', '产品型号管理');
|
||
$this->assign('user', $user);
|
||
$this->assign('groupedModels', $ordered);
|
||
$this->assign('typeConfigs', $typeConfigs);
|
||
$this->assign('typeColorMap', $typeColorMap);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function productModelAdd()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
|
||
$type = trim($_POST['type'] ?? '');
|
||
$product_type = trim($_POST['product_type'] ?? '');
|
||
$capacity = trim($_POST['capacity'] ?? '');
|
||
$color = trim($_POST['color'] ?? '');
|
||
$model_code = trim($_POST['model_code'] ?? '');
|
||
$model_desc = trim($_POST['description'] ?? '');
|
||
|
||
// 从数据库获取类型配置,表不存在时回退到默认配置
|
||
$typeConfigModel = new ProductTypeConfig();
|
||
$typeConfig = $typeConfigModel->getByTypeName($type);
|
||
if (!$typeConfig) {
|
||
// 数据库配置不可用时,使用硬编码默认配置验证类型
|
||
$defaultConfigs = ProductTypeConfig::getDefaultConfigs();
|
||
$typeNames = [];
|
||
foreach ($defaultConfigs as $dc) {
|
||
$typeNames[] = $dc['type_name'];
|
||
if ($dc['type_name'] === $type) {
|
||
$typeConfig = $dc;
|
||
}
|
||
}
|
||
if (!$typeConfig) {
|
||
$this->assign('error', '类型无效,请选择:' . implode('、', $typeNames));
|
||
$this->assign('title', '添加产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 根据配置决定 product_type 和 capacity 值
|
||
$productTypeField = $typeConfig['product_type_field'] ?? 'product_type';
|
||
if ($productTypeField === 'capacity' && $capacity !== '') {
|
||
// 电池类型:capacity 存入 capacity 字段,product_type 留空
|
||
$product_type = '';
|
||
} elseif ($productTypeField === 'color' && $color !== '') {
|
||
$product_type = $color;
|
||
}
|
||
// 隐藏产品类型输入框的类型 → 设为空
|
||
if (($typeConfig['product_type_state'] ?? 'editable') === 'hidden') {
|
||
$product_type = '';
|
||
}
|
||
|
||
// model_code 输入框隐藏时,用 product_type 值自动填充(如"外壳颜色"类型)
|
||
if (($typeConfig['model_code_state'] ?? 'editable') === 'hidden' && $model_code === '') {
|
||
$model_code = $product_type ?: $color;
|
||
}
|
||
|
||
$productModel = new ProductModel();
|
||
|
||
try {
|
||
// 检测同一产品类型+类型下型号代码是否已存在
|
||
$exist = $productModel->findByTypeAndCode($type, $product_type, $model_code);
|
||
if ($exist) {
|
||
$this->assign('error', '该类型+产品类型下型号「' . htmlspecialchars($model_code) . '」已存在,请勿重复录入!');
|
||
$this->assign('title', '添加产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
|
||
$data = array(
|
||
'type' => $type,
|
||
'product_type' => $product_type,
|
||
'model_code' => $model_code,
|
||
'model_desc' => $model_desc,
|
||
'capacity' => $capacity,
|
||
'color' => $color,
|
||
);
|
||
|
||
$result = $productModel->addModel($data);
|
||
if ($result === false) {
|
||
$err = $productModel->getError();
|
||
$this->assign('error', '添加失败:' . ($err ?: '未知错误'));
|
||
$this->assign('title', '添加产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
error_log('[productModelAdd] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||
$this->assign('error', '添加失败,数据库错误:' . $e->getMessage());
|
||
$this->assign('title', '添加产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/productModel');
|
||
exit;
|
||
}
|
||
|
||
// 传入类型配置列表给视图,表不存在时回退到硬编码默认值
|
||
$typeConfigModel = new ProductTypeConfig();
|
||
$typeConfigs = $typeConfigModel->getEnabled();
|
||
if (empty($typeConfigs)) {
|
||
$typeConfigs = ProductTypeConfig::getDefaultConfigs();
|
||
}
|
||
|
||
$this->assign('title', '添加产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('typeConfigs', $typeConfigs);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function productModelEdit($id)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$productModel = new ProductModel();
|
||
$model = $productModel->where(['id = :id'], [':id' => $id])->fetch();
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
|
||
$type = trim($_POST['type'] ?? '');
|
||
$product_type = trim($_POST['product_type'] ?? '');
|
||
$capacity = trim($_POST['capacity'] ?? '');
|
||
$color = trim($_POST['color'] ?? '');
|
||
$model_code = trim($_POST['model_code'] ?? '');
|
||
$model_desc = trim($_POST['description'] ?? '');
|
||
|
||
// 从数据库获取类型配置,表不存在时回退到默认配置
|
||
$typeConfigModel = new ProductTypeConfig();
|
||
$typeConfig = $typeConfigModel->getByTypeName($type);
|
||
if (!$typeConfig) {
|
||
// 数据库配置不可用时,使用硬编码默认配置验证类型
|
||
$defaultConfigs = ProductTypeConfig::getDefaultConfigs();
|
||
$typeNames = [];
|
||
foreach ($defaultConfigs as $dc) {
|
||
$typeNames[] = $dc['type_name'];
|
||
if ($dc['type_name'] === $type) {
|
||
$typeConfig = $dc;
|
||
}
|
||
}
|
||
if (!$typeConfig) {
|
||
$this->assign('error', '类型无效,请选择:' . implode('、', $typeNames));
|
||
$this->assign('title', '编辑产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('model', $model);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 根据配置决定 product_type 和 capacity 值
|
||
$productTypeField = $typeConfig['product_type_field'] ?? 'product_type';
|
||
if ($productTypeField === 'capacity' && $capacity !== '') {
|
||
// 电池类型:capacity 存入 capacity 字段,product_type 留空
|
||
$product_type = '';
|
||
} elseif ($productTypeField === 'color' && $color !== '') {
|
||
$product_type = $color;
|
||
}
|
||
// 隐藏产品类型输入框的类型 → 设为空
|
||
if (($typeConfig['product_type_state'] ?? 'editable') === 'hidden') {
|
||
$product_type = '';
|
||
}
|
||
|
||
try {
|
||
// 检测同一类型+产品类型下型号代码是否已存在(排除自身)
|
||
$exist = $productModel->findByTypeAndCode($type, $product_type, $model_code);
|
||
if ($exist && $exist['id'] != $id) {
|
||
$this->assign('error', '该类型+产品类型下型号「' . htmlspecialchars($model_code) . '」已存在,请勿重复录入!');
|
||
$this->assign('title', '编辑产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('model', $model);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
|
||
$data = array(
|
||
'type' => $type,
|
||
'product_type' => $product_type,
|
||
'model_code' => $model_code,
|
||
'model_desc' => $model_desc,
|
||
'capacity' => $capacity,
|
||
'color' => $color,
|
||
);
|
||
|
||
$result = $productModel->updateModel($id, $data);
|
||
if ($result === false) {
|
||
$err = $productModel->getError();
|
||
$this->assign('error', '更新失败:' . ($err ?: '未知错误'));
|
||
$this->assign('title', '编辑产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('model', $model);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
error_log('[productModelEdit] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||
$this->assign('error', '更新失败,数据库错误:' . $e->getMessage());
|
||
$this->assign('title', '编辑产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('model', $model);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/productModel');
|
||
exit;
|
||
}
|
||
|
||
if (!$model) {
|
||
exit('产品型号不存在或已被删除');
|
||
}
|
||
|
||
// 传入类型配置列表给视图,表不存在时回退到硬编码默认值
|
||
$typeConfigModel = new ProductTypeConfig();
|
||
$typeConfigs = $typeConfigModel->getEnabled();
|
||
if (empty($typeConfigs)) {
|
||
$typeConfigs = ProductTypeConfig::getDefaultConfigs();
|
||
}
|
||
|
||
$this->assign('title', '编辑产品型号');
|
||
$this->assign('user', $user);
|
||
$this->assign('model', $model);
|
||
$this->assign('typeConfigs', $typeConfigs);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function productModelDelete($id)
|
||
{
|
||
$this->checkLogin();
|
||
$this->requireMethod('POST');
|
||
$this->csrfVerify();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$productModel = new ProductModel();
|
||
$productModel->deleteModel($id);
|
||
header('Location: ' . BASE_URL . '/Admin/productModel');
|
||
}
|
||
|
||
/**
|
||
* 产品类型配置管理页面
|
||
* 管理员可以在页面中增删改类型,定义每种类型的显示列、标签等
|
||
*/
|
||
public function productTypeConfig()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$typeConfigModel = new ProductTypeConfig();
|
||
|
||
// AJAX: 保存(添加/更新)
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
$action = $_POST['action'] ?? 'save';
|
||
|
||
if ($action === 'delete') {
|
||
$id = intval($_POST['id'] ?? 0);
|
||
if ($id > 0) {
|
||
$result = $typeConfigModel->deleteConfig($id);
|
||
if ($result === false) {
|
||
echo json_encode(['success' => false, 'error' => '删除失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']);
|
||
exit;
|
||
}
|
||
}
|
||
echo json_encode(['success' => true]);
|
||
exit;
|
||
}
|
||
|
||
// save 操作
|
||
$id = intval($_POST['id'] ?? 0);
|
||
$type_name = trim($_POST['type_name'] ?? '');
|
||
$sort_order = intval($_POST['sort_order'] ?? 0);
|
||
$display_columns = trim($_POST['display_columns'] ?? '');
|
||
$product_type_state = trim($_POST['product_type_state'] ?? 'editable');
|
||
$product_type_label = trim($_POST['product_type_label'] ?? '产品类型');
|
||
$product_type_field = trim($_POST['product_type_field'] ?? 'product_type');
|
||
$product_type_placeholder = trim($_POST['product_type_placeholder'] ?? '');
|
||
$model_code_state = trim($_POST['model_code_state'] ?? 'editable');
|
||
$color_state = trim($_POST['color_state'] ?? 'editable');
|
||
$color = trim($_POST['color'] ?? 'default');
|
||
$enabled = intval($_POST['enabled'] ?? 1);
|
||
$show_in_inbound = intval($_POST['show_in_inbound'] ?? 1);
|
||
|
||
// 校验三态值合法性
|
||
$validStates = ['hidden', 'readonly', 'editable'];
|
||
if (!in_array($product_type_state, $validStates)) $product_type_state = 'editable';
|
||
if (!in_array($model_code_state, $validStates)) $model_code_state = 'editable';
|
||
if (!in_array($color_state, $validStates)) $color_state = 'editable';
|
||
|
||
if ($type_name === '') {
|
||
echo json_encode(['success' => false, 'error' => '类型名称不能为空']);
|
||
exit;
|
||
}
|
||
|
||
$data = [
|
||
'type_name' => $type_name,
|
||
'sort_order' => $sort_order,
|
||
'display_columns' => $display_columns,
|
||
'product_type_state' => $product_type_state,
|
||
'product_type_label' => $product_type_label,
|
||
'product_type_field' => $product_type_field,
|
||
'product_type_placeholder' => $product_type_placeholder,
|
||
'model_code_state' => $model_code_state,
|
||
'color_state' => $color_state,
|
||
'color' => $color,
|
||
'enabled' => $enabled,
|
||
'show_in_inbound' => $show_in_inbound,
|
||
];
|
||
|
||
if ($id > 0) {
|
||
$result = $typeConfigModel->updateConfig($id, $data);
|
||
if ($result === false) {
|
||
echo json_encode(['success' => false, 'error' => '更新失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']);
|
||
exit;
|
||
}
|
||
} else {
|
||
// 检查重名
|
||
$exist = $typeConfigModel->getByTypeName($type_name);
|
||
if ($exist) {
|
||
echo json_encode(['success' => false, 'error' => '类型「' . $type_name . '」已存在']);
|
||
exit;
|
||
}
|
||
$result = $typeConfigModel->addConfig($data);
|
||
if ($result === false) {
|
||
echo json_encode(['success' => false, 'error' => '添加失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']);
|
||
exit;
|
||
}
|
||
}
|
||
|
||
echo json_encode(['success' => true]);
|
||
exit;
|
||
}
|
||
|
||
// GET: 渲染页面
|
||
$configs = $typeConfigModel->getAll();
|
||
|
||
$this->assign('title', '产品类型配置');
|
||
$this->assign('user', $user);
|
||
$this->assign('configs', $configs);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
// 工位管理 — 仅超级管理员
|
||
public function workstation()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$workstation = new Workstation();
|
||
$stations = $workstation->getAll();
|
||
|
||
$this->assign('title', '工位管理');
|
||
$this->assign('user', $user);
|
||
$this->assign('stations', $stations);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function workstationAdd()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
// 准备模板字段配置数据(提前初始化,供错误分支和 GET 视图使用)
|
||
require_once APP_PATH . 'app/helpers/field_helper.php';
|
||
$templateTypes = ['inbound', 'pcb_test', 'battery', 'assembly', 'finished_test', 'warehouse', 'delivery'];
|
||
$templateFieldsMap = [];
|
||
foreach ($templateTypes as $type) {
|
||
$templateFieldsMap[$type] = getDefaultFieldsConfig($type);
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
$stationType = trim($_POST['station_type']);
|
||
|
||
// 校验工位类型格式:必须以字母开头,只能包含字母数字和下划线
|
||
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $stationType)) {
|
||
$this->assign('error', '工位类型格式不正确,必须以字母开头,只能包含字母、数字和下划线');
|
||
$this->assign('title', '添加工位');
|
||
$this->assign('user', $user);
|
||
$this->assign('templateFieldsMap', $templateFieldsMap);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
|
||
// 状态值验证
|
||
$status = intval($_POST['status'] ?? 1);
|
||
if (!in_array($status, [0, 1], true)) {
|
||
$status = 1; // 默认启用
|
||
}
|
||
|
||
try {
|
||
$data = array(
|
||
'station_name' => $_POST['station_name'],
|
||
'station_code' => $_POST['station_code'],
|
||
'station_type' => $stationType,
|
||
'sort_order' => intval($_POST['sort_order'] ?? 1),
|
||
'status' => $status,
|
||
'fields_config' => $_POST['fields_config'] ?? ''
|
||
);
|
||
|
||
// 如果未传入配置,则尝试从模板复制或使用默认配置
|
||
if (empty($data['fields_config'])) {
|
||
$templateType = $_POST['template_station'] ?? '';
|
||
|
||
if (!empty($templateType)) {
|
||
$templateConfig = getDefaultFieldsConfig($templateType);
|
||
$data['fields_config'] = json_encode($templateConfig, JSON_UNESCAPED_UNICODE);
|
||
} else {
|
||
$data['fields_config'] = json_encode([], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
}
|
||
|
||
$workstation = new Workstation();
|
||
$workstation->addStation($data);
|
||
header('Location: ' . BASE_URL . '/Admin/workstation');
|
||
exit;
|
||
} catch (\Throwable $e) {
|
||
error_log('[workstationAdd] ' . $e->getMessage());
|
||
$this->assign('error', '添加失败:' . $e->getMessage());
|
||
$this->assign('title', '添加工位');
|
||
$this->assign('user', $user);
|
||
$this->assign('templateFieldsMap', $templateFieldsMap);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
$this->assign('title', '添加工位');
|
||
$this->assign('user', $user);
|
||
$this->assign('templateFieldsMap', $templateFieldsMap);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function workstationEdit($id)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
require_once APP_PATH . 'app/helpers/field_helper.php';
|
||
|
||
$workstation = new Workstation();
|
||
$station = $workstation->where(['id = :id'], [':id' => $id])->fetch();
|
||
|
||
// 加载工位定义元数据
|
||
$stationDef = new StationDefinition();
|
||
$stationDefinition = $stationDef->getByType($station['station_type']);
|
||
|
||
// 解析字段配置用于编辑器(提前初始化,供错误分支使用)
|
||
$fieldEditor = renderFieldConfigEditor($station['station_type'], $station['fields_config'] ?? '', $stationDefinition);
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
|
||
// 状态值验证:只允许 0(禁用)和 1(启用)
|
||
$status = intval($_POST['status']);
|
||
if (!in_array($status, [0, 1], true)) {
|
||
$this->assign('error', '状态值无效,请选择启用或禁用');
|
||
$this->assign('title', '编辑工位');
|
||
$this->assign('user', $user);
|
||
$this->assign('station', $station);
|
||
$this->assign('fieldEditor', $fieldEditor);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$data = array(
|
||
'station_name' => $_POST['station_name'],
|
||
'sort_order' => $_POST['sort_order'],
|
||
'status' => $status,
|
||
'fields_config' => $_POST['fields_config'] ?? ''
|
||
);
|
||
|
||
$workstation->updateStation($id, $data);
|
||
|
||
// 同步更新 station_definitions 表(管控产品类型行为)
|
||
$sdData = [
|
||
'has_product_type' => isset($_POST['sd_has_product_type']) ? 1 : 0,
|
||
'has_product_model' => isset($_POST['sd_has_product_model']) ? 1 : 0,
|
||
'filter_enabled' => isset($_POST['sd_filter_enabled']) ? 1 : 0,
|
||
'show_today_count' => isset($_POST['sd_show_today_count']) ? 1 : 0,
|
||
'show_total_count' => isset($_POST['sd_show_total_count']) ? 1 : 0,
|
||
];
|
||
|
||
if ($stationDefinition && !empty($stationDefinition['id'])) {
|
||
// 更新已有定义
|
||
$sdFields = [];
|
||
$sdParams = [];
|
||
foreach ($sdData as $key => $val) {
|
||
$sdFields[] = "`{$key}` = :{$key}";
|
||
$sdParams[":{$key}"] = $val;
|
||
}
|
||
$sdParams[':id'] = $stationDefinition['id'];
|
||
$stationDef->execute(
|
||
"UPDATE `" . StationDefinition::TABLE_PREFIX . "station_definitions` SET " . implode(', ', $sdFields) . " WHERE id = :id",
|
||
$sdParams
|
||
);
|
||
// 更新已有工位:同步字段变更到数据表
|
||
StationDefinition::clearCache();
|
||
$updatedDef = $stationDef->getByType($station['station_type']);
|
||
$fieldConfigs = parseFieldsConfig($station['station_type'], $_POST['fields_config'] ?? '');
|
||
unset($fieldConfigs['_meta']);
|
||
$stationDef->ensureColumns($station['station_type'], array_values($fieldConfigs));
|
||
} else {
|
||
// 新建定义
|
||
$sdData['station_type'] = $station['station_type'];
|
||
$sdData['db_table'] = 'station_' . $station['station_type'];
|
||
$sdData['time_column'] = 'created_at';
|
||
$sdFields = implode('`, `', array_keys($sdData));
|
||
$sdPlaceholders = ':' . implode(', :', array_keys($sdData));
|
||
$stationDef->execute(
|
||
"INSERT INTO `" . StationDefinition::TABLE_PREFIX . "station_definitions` (`{$sdFields}`) VALUES ({$sdPlaceholders})",
|
||
$sdData
|
||
);
|
||
// 新工位:自动创建数据表
|
||
StationDefinition::clearCache();
|
||
$newDef = $stationDef->getByType($station['station_type']);
|
||
$fieldConfigs = parseFieldsConfig($station['station_type'], $_POST['fields_config'] ?? '');
|
||
unset($fieldConfigs['_meta']);
|
||
$stationDef->ensureTable($station['station_type'], array_values($fieldConfigs));
|
||
}
|
||
|
||
// 清除缓存,确保下次读取时获取最新数据
|
||
StationDefinition::clearCache();
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/workstation');
|
||
exit;
|
||
} catch (\Throwable $e) {
|
||
error_log('[workstationEdit] ' . $e->getMessage());
|
||
$this->assign('error', '更新失败:' . $e->getMessage());
|
||
$this->assign('title', '编辑工位');
|
||
$this->assign('user', $user);
|
||
$this->assign('station', $station);
|
||
$this->assign('fieldEditor', $fieldEditor);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
$this->assign('title', '编辑工位');
|
||
$this->assign('user', $user);
|
||
$this->assign('station', $station);
|
||
$this->assign('fieldEditor', $fieldEditor);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
public function workstationDelete($id)
|
||
{
|
||
$this->checkLogin();
|
||
$this->requireMethod('POST');
|
||
$this->csrfVerify();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$workstation = new Workstation();
|
||
$workstation->deleteStation($id);
|
||
header('Location: ' . BASE_URL . '/Admin/workstation');
|
||
}
|
||
|
||
// 系统配置 — 仅超级管理员可访问
|
||
public function config()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$config = new SysConfig();
|
||
$sysConfig = $config->getConfig();
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
try {
|
||
$data = array(
|
||
'company_name' => $_POST['company_name'],
|
||
'mes_name' => $_POST['mes_name']
|
||
);
|
||
|
||
$config->updateConfig($data);
|
||
|
||
// 保存代码前缀规则
|
||
if (isset($_POST['prefix_types']) && isset($_POST['prefix_values'])) {
|
||
$types = $_POST['prefix_types'];
|
||
$values = $_POST['prefix_values'];
|
||
$rules = [];
|
||
$typeCount = count($types);
|
||
for ($i = 0; $i < $typeCount; $i++) {
|
||
$typeName = trim($types[$i]);
|
||
$prefixVal = trim($values[$i]);
|
||
if ($typeName !== '') {
|
||
$rules[$typeName] = $prefixVal;
|
||
}
|
||
}
|
||
$config->updateCodePrefixRules($rules);
|
||
}
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/config');
|
||
exit;
|
||
} catch (\Throwable $e) {
|
||
error_log('[config] ' . $e->getMessage());
|
||
$this->assign('error', '保存失败:' . $e->getMessage());
|
||
$this->assign('title', '系统配置');
|
||
$this->assign('user', $user);
|
||
$this->assign('sysConfig', $sysConfig);
|
||
$this->assign('codePrefixRules', $config->getCodePrefixRules());
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
$this->assign('title', '系统配置');
|
||
$this->assign('user', $user);
|
||
$this->assign('sysConfig', $sysConfig);
|
||
$this->assign('codePrefixRules', $config->getCodePrefixRules());
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
// 批量导入 — 仅超级管理员
|
||
public function import()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$msg = '';
|
||
|
||
// 表类型 -> 数据库表名 映射
|
||
$tableMap = [
|
||
'employee' => 'employee',
|
||
'customer' => 'customer',
|
||
'product_model' => 'product_model',
|
||
'inbound' => 'station_inbound',
|
||
'pcb_test' => 'pcb_test',
|
||
'battery' => 'battery_status',
|
||
'assembly' => 'product_assembly',
|
||
'finished_test' => 'finished_test',
|
||
'warehouse' => 'warehouse_in',
|
||
'delivery' => 'delivery',
|
||
];
|
||
|
||
// 需要排除的列(自动生成的字段)
|
||
$excludeColumns = ['id', 'created_at', 'updated_at'];
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['import_file'])) {
|
||
$this->csrfVerify();
|
||
$tableType = $_POST['table_type'] ?? '';
|
||
if (!isset($tableMap[$tableType])) {
|
||
$msg = '无效的数据表类型';
|
||
} else {
|
||
$tableName = $tableMap[$tableType];
|
||
$file = $_FILES['import_file'];
|
||
|
||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||
$msg = '文件上传失败,错误码:' . $file['error'];
|
||
} else {
|
||
// 验证文件类型:仅允许 CSV
|
||
$tmpPath = $file['tmp_name'];
|
||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||
$mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $tmpPath);
|
||
$allowedExt = ['csv'];
|
||
$allowedMime = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel'];
|
||
if (!in_array($ext, $allowedExt, true) || !in_array($mime, $allowedMime, true)) {
|
||
$msg = '仅支持 CSV 格式文件,当前文件类型:' . htmlspecialchars($ext) . ' (' . htmlspecialchars($mime) . ')';
|
||
error_log('[import] Upload rejected: ext=' . $ext . ' mime=' . $mime);
|
||
$this->assign('title', '批量导入');
|
||
$this->assign('user', $user);
|
||
$this->assign('msg', $msg);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
return;
|
||
}
|
||
|
||
$handle = fopen($tmpPath, 'r');
|
||
if (!$handle) {
|
||
$msg = '无法打开文件';
|
||
} else {
|
||
// 读取 CSV 头部
|
||
$headers = fgetcsv($handle);
|
||
if (!$headers) {
|
||
$msg = 'CSV 文件为空';
|
||
} else {
|
||
// BOM 处理
|
||
$headers[0] = trim($headers[0], "\xEF\xBB\xBF");
|
||
$headers = array_map('trim', $headers);
|
||
|
||
// 过滤掉排除列
|
||
$validHeaders = [];
|
||
foreach ($headers as $h) {
|
||
if (!in_array($h, $excludeColumns)) {
|
||
$validHeaders[] = $h;
|
||
}
|
||
}
|
||
|
||
// 解析每行数据
|
||
$success = 0;
|
||
$fail = 0;
|
||
$model = new \core\base\Model();
|
||
$model->table($tableName);
|
||
|
||
while (($row = fgetcsv($handle)) !== false) {
|
||
$data = [];
|
||
foreach ($headers as $i => $col) {
|
||
if (!in_array($col, $excludeColumns) && isset($row[$i])) {
|
||
$val = $row[$i] !== '' ? $row[$i] : null;
|
||
// 员工密码自动 bcrypt 加密
|
||
if ($tableName === 'employee' && $col === 'password' && $val !== null) {
|
||
$empModel = new \app\models\Employee();
|
||
$val = $empModel->hashPassword($val);
|
||
}
|
||
$data[$col] = $val;
|
||
}
|
||
}
|
||
if (!empty($data)) {
|
||
try {
|
||
$model->add($data);
|
||
$success++;
|
||
} catch (\Exception $e) {
|
||
$fail++;
|
||
}
|
||
}
|
||
}
|
||
$msg = "导入完成:成功 {$success} 条,失败 {$fail} 条";
|
||
}
|
||
fclose($handle);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->assign('title', '批量导入');
|
||
$this->assign('user', $user);
|
||
$this->assign('msg', $msg);
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
// 下载导入模板 — 仅超级管理员
|
||
public function importTemplate($type)
|
||
{
|
||
$this->checkLogin();
|
||
|
||
$user = $this->getCurrentUser();
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$tableMap = [
|
||
'employee' => 'employee',
|
||
'customer' => 'customer',
|
||
'product_model' => 'product_model',
|
||
'inbound' => 'station_inbound',
|
||
'pcb_test' => 'pcb_test',
|
||
'battery' => 'battery_status',
|
||
'assembly' => 'product_assembly',
|
||
'finished_test' => 'finished_test',
|
||
'warehouse' => 'warehouse_in',
|
||
'delivery' => 'delivery',
|
||
];
|
||
|
||
if (!isset($tableMap[$type])) {
|
||
exit('无效的模板类型');
|
||
}
|
||
|
||
$tableName = $tableMap[$type];
|
||
$excludeCols = ['id', 'created_at', 'updated_at'];
|
||
|
||
$model = new \core\base\Model();
|
||
$model->table($tableName);
|
||
$fullTableName = $model->getTable();
|
||
$columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`");
|
||
|
||
$headers = [];
|
||
foreach ($columns as $col) {
|
||
if (!in_array($col['Field'], $excludeCols)) {
|
||
$headers[] = $col['Field'];
|
||
}
|
||
}
|
||
|
||
header('Content-Type: text/csv; charset=utf-8');
|
||
header('Content-Disposition: attachment; filename="' . $type . '_template.csv"');
|
||
header('Pragma: no-cache');
|
||
|
||
// BOM for Excel UTF-8 compatibility
|
||
echo "\xEF\xBB\xBF";
|
||
$output = fopen('php://output', 'w');
|
||
fputcsv($output, $headers);
|
||
fclose($output);
|
||
exit;
|
||
}
|
||
|
||
// 产品追溯 - 跨表查询产品完整信息 — 仅超级管理员
|
||
public function productSearch()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$keyword = trim($_GET['keyword'] ?? '');
|
||
$results = [];
|
||
$searched = false;
|
||
|
||
if (!empty($keyword)) {
|
||
$searched = true;
|
||
$model = new \core\base\Model();
|
||
|
||
// 用 LIKE 跨 product_assembly 表搜索
|
||
$like = '%' . $keyword . '%';
|
||
$results = $model->query(
|
||
"SELECT pa.*,
|
||
COALESCE(bs.voltage_platform, '') AS voltage_platform,
|
||
COALESCE(bs.no_load_voltage, '') AS no_load_voltage,
|
||
COALESCE(bs.internal_resistance, '') AS internal_resistance,
|
||
COALESCE(pt.test_status, '') AS pcb_test_status,
|
||
COALESCE(ft.test_result, '') AS finished_test_result,
|
||
COALESCE(wi.in_time, '') AS warehouse_time,
|
||
COALESCE(d.customer_name, '') AS delivery_customer,
|
||
COALESCE(d.delivery_time, '') AS delivery_time
|
||
FROM DGZXY_product_assembly pa
|
||
LEFT JOIN DGZXY_battery_status bs ON pa.battery_serial = bs.serial_no
|
||
LEFT JOIN DGZXY_pcb_test pt ON pa.pcb_serial = pt.serial_no
|
||
LEFT JOIN DGZXY_finished_test ft ON pa.finished_serial = ft.finished_serial
|
||
LEFT JOIN DGZXY_warehouse_in wi ON pa.finished_serial = wi.finished_serial
|
||
LEFT JOIN DGZXY_delivery d ON pa.finished_serial = d.finished_serial
|
||
WHERE pa.finished_serial LIKE :kw1
|
||
OR pa.battery_serial LIKE :kw2
|
||
OR pa.pcb_serial LIKE :kw3
|
||
OR pa.product_model LIKE :kw4
|
||
ORDER BY pa.id DESC
|
||
LIMIT 200",
|
||
[':kw1' => $like, ':kw2' => $like, ':kw3' => $like, ':kw4' => $like]
|
||
);
|
||
}
|
||
|
||
$this->assign('title', '产品追溯');
|
||
$this->assign('user', $user);
|
||
$this->assign('keyword', $keyword);
|
||
$this->assign('results', $results);
|
||
$this->assign('searched', $searched);
|
||
$this->assign('activeMenu', 'productSearch');
|
||
$this->render();
|
||
}
|
||
|
||
// ========== 数据库敏感字段脱敏(P0:防止密码哈希等敏感信息泄露) ==========
|
||
// 命中以下字段名(含子串匹配)的值在数据库管理页面一律显示为 ******,且禁止通过编辑页修改
|
||
protected static $SENSITIVE_FIELDS = [
|
||
'password', 'passwd', 'pwd', 'token', 'api_token', 'access_token',
|
||
'refresh_token', 'secret', 'secret_key', 'api_key', 'private_key',
|
||
'salt', 'auth_key', 'mini_token', 'session_token', 'reset_token',
|
||
'verify_token', 'enc_key', 'cert',
|
||
];
|
||
|
||
protected function isSensitiveField($field)
|
||
{
|
||
$f = strtolower(trim((string)$field));
|
||
foreach (self::$SENSITIVE_FIELDS as $s) {
|
||
if ($f === $s || strpos($f, $s) !== false) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// P2-10:敏感系统表(含 token/密钥/凭证语义)不在数据库管理界面暴露
|
||
protected static $SENSITIVE_TABLE_PATTERNS = [
|
||
'token', 'api_token', 'secret', 'password', 'passwd', 'pwd',
|
||
'session', 'miniapp', 'asd_api', 'oauth', 'credential', 'auth',
|
||
'salt', 'key', 'encrypt', 'private',
|
||
'permission', // 权限映射表(员工权限/员工工位权限)不暴露,防权限结构泄露
|
||
];
|
||
|
||
protected function isSensitiveTable($table)
|
||
{
|
||
$t = strtolower(trim((string)$table));
|
||
foreach (self::$SENSITIVE_TABLE_PATTERNS as $p) {
|
||
if (strpos($t, $p) !== false) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// v3-P2:系统/框架级表(迁移、系统配置等)不在数据库管理界面暴露,
|
||
// 进一步缩小可见表范围,降低结构信息泄露面。
|
||
protected static $SYSTEM_TABLE_PATTERNS = [
|
||
'migration', 'phinxlog', 'sys_config', 'sysconfig', 'system_config',
|
||
'schema_version', 'seeds', 'cache', 'sessions', 'jobs', 'failed_jobs',
|
||
'logs', 'sys_log', 'audit',
|
||
];
|
||
|
||
protected function isSystemTable($table)
|
||
{
|
||
$t = strtolower(trim((string)$table));
|
||
foreach (self::$SYSTEM_TABLE_PATTERNS as $p) {
|
||
if (strpos($t, $p) !== false) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// v5-P2:数据库表业务名称映射(用于管理界面以中文业务名展示,降低原始表名结构泄露)
|
||
// key 为去掉前缀后的展示名,value 为中文业务名;未命中时回退为原始展示名。
|
||
protected static $TABLE_BUSINESS_NAMES = [
|
||
'asd_device' => '昂盛达设备',
|
||
'asd_test_data' => '昂盛达测试数据',
|
||
'asd_test_detail' => '昂盛达测试明细',
|
||
'asd_test_record' => '昂盛达测试记录',
|
||
'asset' => '资产',
|
||
'battery_status' => '电池状态',
|
||
'customer' => '客户',
|
||
'delivery' => '出货单',
|
||
'document' => '文档',
|
||
'employee' => '员工',
|
||
'finance_record' => '财务记录',
|
||
'finished_test' => '成品测试',
|
||
'hr_employee' => '人事员工',
|
||
'import_template' => '导入模板',
|
||
'inventory' => '库存',
|
||
'pcb_test' => 'PCB 测试',
|
||
'product_assembly' => '产品装配',
|
||
'product_model' => '产品型号',
|
||
'product_type_config' => '产品类型配置',
|
||
'purchase_item' => '采购明细',
|
||
'purchase_order' => '采购订单',
|
||
'receivable_payable' => '应收应付',
|
||
'sales_item' => '销售明细',
|
||
'sales_order' => '销售订单',
|
||
'station_aging' => '工位老化',
|
||
'station_definitions' => '工位定义',
|
||
'station_inbound' => '工位入库',
|
||
'station_records' => '工位记录',
|
||
'station_semi_finished' => '工位半成品',
|
||
'supplier' => '供应商',
|
||
'warehouse_in' => '入库',
|
||
'workstation' => '工位',
|
||
];
|
||
|
||
protected function tableBusinessName($displayName)
|
||
{
|
||
return self::$TABLE_BUSINESS_NAMES[$displayName] ?? $displayName;
|
||
}
|
||
|
||
|
||
// v3-P2:数据库管理操作审计日志(轻量文件日志,便于事后追溯谁访问/改动了哪张表)
|
||
// 日志写入 runtime/db_audit.log,.htaccess 已禁止 .log 通过 Web 访问。
|
||
protected function dbAuditLog($action, $table = '', $extra = '')
|
||
{
|
||
try {
|
||
$user = $this->getCurrentUser();
|
||
$dir = APP_PATH . 'runtime';
|
||
if (!is_dir($dir)) {
|
||
@mkdir($dir, 0750, true);
|
||
}
|
||
$ip = $_SERVER['REMOTE_ADDR'] ?? '-';
|
||
$line = sprintf(
|
||
"[%s] user=%s(%s) role=%s ip=%s action=%s table=%s %s\n",
|
||
date('Y-m-d H:i:s'),
|
||
$user['emp_no'] ?? '-',
|
||
$user['emp_name'] ?? '-',
|
||
$user['role'] ?? '-',
|
||
$ip,
|
||
$action,
|
||
$table !== '' ? $table : '-',
|
||
$extra
|
||
);
|
||
@file_put_contents($dir . '/db_audit.log', $line, FILE_APPEND | LOCK_EX);
|
||
} catch (\Throwable $e) {
|
||
// 审计失败不得影响主流程
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// 对行集合中的敏感字段值脱敏(原地修改)
|
||
protected function maskSensitiveRows(array &$rows)
|
||
{
|
||
foreach ($rows as &$row) {
|
||
if (!is_array($row)) continue;
|
||
foreach ($row as $k => $v) {
|
||
if ($this->isSensitiveField($k)) {
|
||
$row[$k] = '******';
|
||
}
|
||
}
|
||
}
|
||
unset($row);
|
||
}
|
||
|
||
// 数据库管理 - 表列表 — 仅超级管理员可访问
|
||
public function database()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// v3-P2 安全加固:原始数据库管理(可查看/编辑/删除任意表记录)属高危操作,
|
||
// 仅超级管理员可访问,对普通管理员完全隐藏表名与记录数。
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
$this->dbAuditLog('list_tables');
|
||
|
||
$tableList = $this->getVisibleTables();
|
||
|
||
$this->assign('title', '数据库管理');
|
||
$this->assign('user', $user);
|
||
$this->assign('tables', $tableList);
|
||
$this->assign('activeMenu', 'database');
|
||
$this->render();
|
||
}
|
||
|
||
// v7-P3:可见表列表(过滤敏感/系统表、去前缀、按展示名稳定排序)
|
||
// 同时供列表页与详情页按索引定位,避免 URL 中暴露原始表名
|
||
protected function getVisibleTables()
|
||
{
|
||
$model = new \core\base\Model();
|
||
$tables = $model->query("SHOW TABLES");
|
||
$prefix = \core\base\Model::TABLE_PREFIX;
|
||
$list = [];
|
||
foreach ($tables as $row) {
|
||
$tbl = reset($row); // 获取第一个值(表名)
|
||
// 跳过 jp_ 前缀的表(不显示)
|
||
if (strpos($tbl, 'jp_') === 0) {
|
||
continue;
|
||
}
|
||
// P2-10 安全加固:敏感系统表不在管理界面暴露(限制显示范围,防结构泄露)
|
||
if ($this->isSensitiveTable($tbl)) {
|
||
continue;
|
||
}
|
||
// v3-P2:框架/系统级表(迁移、系统配置、日志等)同样不暴露
|
||
if ($this->isSystemTable($tbl)) {
|
||
continue;
|
||
}
|
||
// 使用完整表名查询行数
|
||
$count = $model->query("SELECT COUNT(*) AS cnt FROM `{$tbl}`");
|
||
// 展示给用户的名称:去掉 DGZXY_ 前缀
|
||
$displayName = $tbl;
|
||
if (strpos($tbl, $prefix) === 0) {
|
||
$displayName = substr($tbl, strlen($prefix));
|
||
}
|
||
$list[] = [
|
||
'name' => $displayName,
|
||
'business_name' => $this->tableBusinessName($displayName),
|
||
'full_name' => $tbl,
|
||
'count' => $count[0]['cnt'] ?? 0,
|
||
];
|
||
}
|
||
// 按展示名排序,保证索引在各请求间一致(修复 v7-P3:URL 不暴露原始表名)
|
||
usort($list, function ($a, $b) {
|
||
return strcmp($a['name'], $b['name']);
|
||
});
|
||
return $list;
|
||
}
|
||
|
||
// 数据库管理 - 查看表数据 — 仅超级管理员可访问
|
||
// v7-P3:$idx 为 getVisibleTables() 的稳定索引,URL 不再含原始表名
|
||
public function databaseView($idx = 0)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// v3-P2:原始数据库查看仅超级管理员可访问
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
// v7-P3:通过稳定索引解析表名,不在 URL 暴露原始表名
|
||
$tables = $this->getVisibleTables();
|
||
$idx = intval($idx);
|
||
if (!isset($tables[$idx])) {
|
||
echo '<h3>无效的表索引</h3>';
|
||
exit;
|
||
}
|
||
$table = $tables[$idx]['name'];
|
||
|
||
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
|
||
$perPage = 20;
|
||
|
||
$model = new \core\base\Model();
|
||
$model->table($table);
|
||
$fullTableName = $model->getTable();
|
||
|
||
// 验证表存在(使用参数化查询,用完整表名匹配)
|
||
$check = $model->query("SHOW TABLES LIKE :tbl", [':tbl' => $fullTableName]);
|
||
if (empty($check)) {
|
||
echo '<h3>表不存在</h3>';
|
||
exit;
|
||
}
|
||
|
||
// 获取列信息
|
||
$columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`");
|
||
|
||
// 总数
|
||
$total = $model->query("SELECT COUNT(*) AS cnt FROM `{$fullTableName}`");
|
||
$totalCount = $total[0]['cnt'] ?? 0;
|
||
$totalPages = ceil($totalCount / $perPage);
|
||
$offset = ($page - 1) * $perPage;
|
||
|
||
// 获取数据(offset/limit 已 intval 安全)
|
||
$rows = $model->query("SELECT * FROM `{$fullTableName}` ORDER BY id DESC LIMIT {$offset}, {$perPage}");
|
||
|
||
// P0 脱敏:密码/token 等敏感字段不展示明文
|
||
$this->maskSensitiveRows($rows);
|
||
|
||
$this->assign('title', '查看表:' . $table);
|
||
$this->assign('user', $user);
|
||
$this->assign('table', $table);
|
||
$this->assign('idx', $idx);
|
||
$this->assign('columns', $columns);
|
||
$this->assign('rows', $rows);
|
||
$this->assign('page', $page);
|
||
$this->assign('totalPages', $totalPages);
|
||
$this->assign('totalCount', $totalCount);
|
||
$this->assign('activeMenu', 'database');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
// 数据库管理 - 编辑行 — 仅超级管理员可访问
|
||
// v7-P3:$idx 为 getVisibleTables() 的稳定索引
|
||
public function databaseEdit($idx = 0, $id = 0)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// v3-P2:原始数据库编辑仅超级管理员可访问
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
// v7-P3:通过稳定索引解析表名
|
||
$tables = $this->getVisibleTables();
|
||
$idx = intval($idx);
|
||
if (!isset($tables[$idx])) {
|
||
echo '<h3>无效的表索引</h3>';
|
||
exit;
|
||
}
|
||
$table = $tables[$idx]['name'];
|
||
$this->dbAuditLog('edit_record', $table, 'id=' . intval($id));
|
||
|
||
$model = new \core\base\Model();
|
||
$model->table($table);
|
||
$fullTableName = $model->getTable();
|
||
|
||
$columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`");
|
||
$row = $model->query("SELECT * FROM `{$fullTableName}` WHERE id = :id", [':id' => intval($id)]);
|
||
|
||
if (empty($row)) {
|
||
echo '<h3>记录不存在</h3>';
|
||
exit;
|
||
}
|
||
$row = $row[0];
|
||
|
||
// P0 脱敏:识别并标记敏感字段,显示值脱敏
|
||
$sensitiveFields = [];
|
||
foreach ($columns as $col) {
|
||
if ($this->isSensitiveField($col['Field'])) {
|
||
$sensitiveFields[] = $col['Field'];
|
||
}
|
||
}
|
||
foreach ($row as $k => $v) {
|
||
if ($this->isSensitiveField($k)) {
|
||
$row[$k] = '******';
|
||
}
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
$data = [];
|
||
foreach ($columns as $col) {
|
||
$field = $col['Field'];
|
||
if ($field === 'id') continue;
|
||
// 禁止通过数据库编辑页修改密码/token 等敏感字段
|
||
if ($this->isSensitiveField($field)) continue;
|
||
if (isset($_POST[$field])) {
|
||
$data[$field] = $_POST[$field] !== '' ? $_POST[$field] : null;
|
||
}
|
||
}
|
||
|
||
if (!empty($data)) {
|
||
$model->where('id = :id', [':id' => $id])->update($data);
|
||
}
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/databaseView/' . $idx);
|
||
exit;
|
||
}
|
||
|
||
$this->assign('title', '编辑记录');
|
||
$this->assign('user', $user);
|
||
$this->assign('table', $table);
|
||
$this->assign('idx', $idx);
|
||
$this->assign('columns', $columns);
|
||
$this->assign('row', $row);
|
||
$this->assign('sensitiveFields', $sensitiveFields);
|
||
$this->assign('activeMenu', 'database');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
// 数据库管理 - 删除行 — 仅超级管理员可访问
|
||
// v7-P3:$idx 为 getVisibleTables() 的稳定索引
|
||
public function databaseDelete($idx = 0, $id = 0)
|
||
{
|
||
$this->checkLogin();
|
||
$this->requireMethod('POST');
|
||
$this->csrfVerify();
|
||
$user = $this->getCurrentUser();
|
||
|
||
// v3-P2:原始数据库删除仅超级管理员可访问
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
// v7-P3:通过稳定索引解析表名
|
||
$tables = $this->getVisibleTables();
|
||
$idx = intval($idx);
|
||
if (!isset($tables[$idx])) {
|
||
exit('无效的表索引');
|
||
}
|
||
$table = $tables[$idx]['name'];
|
||
$this->dbAuditLog('delete_record', $table, 'id=' . intval($id));
|
||
|
||
$model = new \core\base\Model();
|
||
$model->table($table);
|
||
$model->delete($id);
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/databaseView/' . $idx);
|
||
exit;
|
||
}
|
||
|
||
// ========== 昂盛达测试数据导入管理 ==========
|
||
|
||
/**
|
||
* 测试数据列表 — 仅超级管理员
|
||
*/
|
||
public function asdTestData()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$keyword = $_GET['keyword'] ?? '';
|
||
$asdModel = new \app\models\AsdTestData();
|
||
|
||
$records = $asdModel->getAllWithStats($keyword);
|
||
$totalCount = $asdModel->countRecords($keyword);
|
||
|
||
$this->assign('title', '测试数据管理');
|
||
$this->assign('user', $user);
|
||
$this->assign('records', $records);
|
||
$this->assign('totalCount', $totalCount);
|
||
$this->assign('keyword', $keyword);
|
||
$this->assign('activeMenu', 'asdTestData');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
/**
|
||
* 测试数据详情 — 仅超级管理员
|
||
*/
|
||
public function asdTestDataView($id)
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$asdModel = new \app\models\AsdTestData();
|
||
$record = $asdModel->findById($id);
|
||
if (!$record) {
|
||
echo '<h3>记录不存在</h3>';
|
||
exit;
|
||
}
|
||
|
||
$details = $asdModel->getDetails($id);
|
||
|
||
$this->assign('title', '测试数据详情');
|
||
$this->assign('user', $user);
|
||
$this->assign('record', $record);
|
||
$this->assign('details', $details);
|
||
$this->assign('activeMenu', 'asdTestData');
|
||
$this->render();
|
||
}
|
||
|
||
/**
|
||
* 删除测试数据 — 仅超级管理员
|
||
*/
|
||
public function asdTestDataDelete($id)
|
||
{
|
||
$this->checkLogin();
|
||
$this->requireMethod('POST');
|
||
$this->csrfVerify();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$asdModel = new \app\models\AsdTestData();
|
||
$asdModel->deleteRecord($id);
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/asdTestData');
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* 手动导入测试数据(模拟昂盛达上位机提交) — 仅超级管理员
|
||
*/
|
||
public function asdTestDataImport()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
// 获取工位列表供选择(容错处理)
|
||
$stations = [];
|
||
try {
|
||
$workstation = new \app\models\Workstation();
|
||
$stations = $workstation->getAll();
|
||
if (!is_array($stations)) $stations = [];
|
||
} catch (\Throwable $e) {
|
||
$stations = [];
|
||
}
|
||
|
||
// 获取设备列表(供扫码选择下拉框使用)
|
||
$devices = [];
|
||
try {
|
||
$deviceModel = new \app\models\AsdDevice();
|
||
$devices = $deviceModel->getAllActive();
|
||
if (!is_array($devices)) $devices = [];
|
||
} catch (\Throwable $e) {
|
||
$devices = [];
|
||
}
|
||
|
||
// CSRF Token 传递给视图
|
||
$csrfToken = $this->csrfToken();
|
||
$this->assign('csrfToken', $csrfToken);
|
||
|
||
// 激活 Tab:优先从 GET 参数 tab 获取,支持 form/json/device
|
||
$activeTab = 'form';
|
||
$tabParam = $_GET['tab'] ?? '';
|
||
if (in_array($tabParam, ['form', 'json', 'device'], true)) {
|
||
$activeTab = $tabParam;
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
$action = $_POST['action'] ?? '';
|
||
|
||
if ($action === 'import_json') {
|
||
// 直接粘贴 JSON 数据导入
|
||
$activeTab = 'json'; // 保持在 JSON 导入 Tab
|
||
$jsonStr = $_POST['json_data'] ?? '';
|
||
|
||
if (empty(trim($jsonStr))) {
|
||
$this->assign('error', '请粘贴 JSON 数据');
|
||
} else {
|
||
$jsonData = json_decode($jsonStr, true);
|
||
if (!$jsonData || !is_array($jsonData)) {
|
||
$this->assign('error', 'JSON 格式错误: ' . json_last_error_msg());
|
||
} elseif (empty($jsonData['QrCode'])) {
|
||
$this->assign('error', 'JSON 缺少必填字段: QrCode(条码/二维码)');
|
||
} elseif (empty($jsonData['DataList']) || !is_array($jsonData['DataList'])) {
|
||
$this->assign('error', 'JSON 缺少必填字段: DataList(测试工步数据)');
|
||
} else {
|
||
$asdModel = new \app\models\AsdTestData();
|
||
$recordId = $asdModel->addRecord([
|
||
'tester' => $jsonData['Tester'] ?? $user['emp_name'],
|
||
'qr_code' => $jsonData['QrCode'],
|
||
'mo' => $jsonData['Mo'] ?? '',
|
||
'site' => $jsonData['Site'] ?? '',
|
||
'station' => $jsonData['Station'] ?? '',
|
||
'work_order' => $jsonData['WorkOrder'] ?? '',
|
||
'project_name' => $jsonData['ProjectName'] ?? '',
|
||
'status' => strtoupper($jsonData['Status'] ?? 'PASS'),
|
||
'group_name' => $jsonData['Group'] ?? '',
|
||
'device' => $jsonData['Device'] ?? '',
|
||
'running_time' => $jsonData['RunningTime'] ?? '',
|
||
'test_time' => $jsonData['TestTime'] ?? date('Y-m-d H:i:s'),
|
||
]);
|
||
|
||
if (!$recordId) {
|
||
$err = $asdModel->getError();
|
||
$this->assign('error', '保存失败: ' . ($err ?: '数据库写入错误'));
|
||
} else {
|
||
$detailCount = 0;
|
||
foreach ($jsonData['DataList'] as $item) {
|
||
$r = $asdModel->addDetail([
|
||
'record_id' => $recordId,
|
||
'seq' => $item['Seq'] ?? 1,
|
||
'test_name' => $item['TestName'] ?? '',
|
||
'test_item' => $item['TestItem'] ?? '',
|
||
'test_units' => $item['TestUnits'] ?? '',
|
||
'data_value' => $item['DataValue'] ?? null,
|
||
'lower_limit' => $item['LowerLimit'] ?? null,
|
||
'upper_limit' => $item['UpperLimit'] ?? null,
|
||
'test_value' => $item['TestValue'] ?? '',
|
||
'test_limit' => $item['TestLimit'] ?? '',
|
||
'test_result' => $item['TestResult'] ?? 'PASS',
|
||
]);
|
||
if ($r) $detailCount++;
|
||
}
|
||
$this->assign('success', "JSON 导入成功!记录ID: {$recordId},工步数: {$detailCount}");
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 表单方式提交
|
||
$activeTab = 'form';
|
||
$dataList = [];
|
||
$stepNames = $_POST['step_name'] ?? [];
|
||
$stepItems = $_POST['step_item'] ?? [];
|
||
$stepUnits = $_POST['step_units'] ?? [];
|
||
$stepValues = $_POST['step_value'] ?? [];
|
||
$stepLower = $_POST['step_lower'] ?? [];
|
||
$stepUpper = $_POST['step_upper'] ?? [];
|
||
$stepResults = $_POST['step_result'] ?? [];
|
||
|
||
for ($i = 0; $i < count($stepNames); $i++) {
|
||
$dataList[] = [
|
||
'Seq' => $i + 1,
|
||
'TestName' => $stepNames[$i] ?? '',
|
||
'TestItem' => $stepItems[$i] ?? '',
|
||
'TestUnits' => $stepUnits[$i] ?? '',
|
||
'DataValue' => is_numeric($stepValues[$i]) ? (float)$stepValues[$i] : null,
|
||
'LowerLimit' => is_numeric($stepLower[$i]) ? (float)$stepLower[$i] : null,
|
||
'UpperLimit' => is_numeric($stepUpper[$i]) ? (float)$stepUpper[$i] : null,
|
||
'TestValue' => !is_numeric($stepValues[$i]) ? ($stepValues[$i] ?? '') : '',
|
||
'TestResult' => $stepResults[$i] ?? 'PASS',
|
||
];
|
||
}
|
||
|
||
$status = 'PASS';
|
||
foreach ($dataList as $step) {
|
||
if ($step['TestResult'] === 'FAIL') {
|
||
$status = 'NG';
|
||
break;
|
||
}
|
||
}
|
||
|
||
$asdModel = new \app\models\AsdTestData();
|
||
$recordId = $asdModel->addRecord([
|
||
'tester' => $user['emp_name'],
|
||
'qr_code' => $_POST['qr_code'] ?? '',
|
||
'mo' => $_POST['mo'] ?? '',
|
||
'site' => $_POST['site'] ?? '',
|
||
'station' => $_POST['station'] ?? '',
|
||
'work_order' => $_POST['work_order'] ?? '',
|
||
'project_name' => $_POST['project_name'] ?? '',
|
||
'status' => $status,
|
||
'group_name' => $_POST['group_name'] ?? '',
|
||
'device' => $_POST['device'] ?? '',
|
||
'running_time' => $_POST['running_time'] ?? '',
|
||
'test_time' => date('Y-m-d H:i:s'),
|
||
]);
|
||
|
||
if ($recordId) {
|
||
foreach ($dataList as $item) {
|
||
$asdModel->addDetail([
|
||
'record_id' => $recordId,
|
||
'seq' => $item['Seq'],
|
||
'test_name' => $item['TestName'],
|
||
'test_item' => $item['TestItem'],
|
||
'test_units' => $item['TestUnits'],
|
||
'data_value' => $item['DataValue'],
|
||
'lower_limit' => $item['LowerLimit'],
|
||
'upper_limit' => $item['UpperLimit'],
|
||
'test_value' => $item['TestValue'],
|
||
'test_result' => $item['TestResult'],
|
||
]);
|
||
}
|
||
$this->assign('success', "表单导入成功!记录ID: {$recordId}");
|
||
} else {
|
||
$err = $asdModel->getError();
|
||
$this->assign('error', '保存失败: ' . ($err ?: '数据库写入错误'));
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->assign('title', '导入测试数据');
|
||
$this->assign('user', $user);
|
||
$this->assign('stations', $stations);
|
||
$this->assign('devices', $devices);
|
||
$this->assign('activeTab', $activeTab);
|
||
$this->assign('activeMenu', 'asdTestData');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
/**
|
||
* 批量删除测试数据(按设备或按条件) — 仅超级管理员
|
||
*/
|
||
public function asdBatchDelete()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
$action = $_POST['action'] ?? '';
|
||
$deviceCode = $_POST['device_code'] ?? '';
|
||
$ids = $_POST['ids'] ?? '';
|
||
|
||
$pdo = \core\db\Db::pdo();
|
||
|
||
if ($action === 'by_device' && !empty($deviceCode)) {
|
||
// 按设备编码删除
|
||
$idsStmt = $pdo->prepare("SELECT id FROM DGZXY_asd_test_record WHERE device = :code");
|
||
$idsStmt->execute([':code' => $deviceCode]);
|
||
$recordIds = $idsStmt->fetchAll(\PDO::FETCH_COLUMN);
|
||
|
||
if (!empty($recordIds)) {
|
||
$placeholders = implode(',', array_fill(0, count($recordIds), '?'));
|
||
$pdo->prepare("DELETE FROM DGZXY_asd_test_detail WHERE record_id IN ({$placeholders})")->execute($recordIds);
|
||
$pdo->prepare("DELETE FROM DGZXY_asd_test_record WHERE device = :code")->execute([':code' => $deviceCode]);
|
||
}
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/asdTestData');
|
||
exit;
|
||
}
|
||
|
||
if ($action === 'by_ids' && !empty($ids)) {
|
||
// 按 ID 列表批量删除
|
||
$idArr = array_map('intval', explode(',', $ids));
|
||
$idArr = array_filter($idArr, function($v) { return $v > 0; });
|
||
|
||
if (!empty($idArr)) {
|
||
$placeholders = implode(',', array_fill(0, count($idArr), '?'));
|
||
$pdo->prepare("DELETE FROM DGZXY_asd_test_detail WHERE record_id IN ({$placeholders})")->execute($idArr);
|
||
$pdo->prepare("DELETE FROM DGZXY_asd_test_record WHERE id IN ({$placeholders})")->execute($idArr);
|
||
}
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/asdTestData');
|
||
exit;
|
||
}
|
||
|
||
if ($action === 'clear_all') {
|
||
// 清空全部测试数据
|
||
$pdo->exec("DELETE FROM DGZXY_asd_test_detail");
|
||
$pdo->exec("DELETE FROM DGZXY_asd_test_record");
|
||
$pdo->exec("ALTER TABLE DGZXY_asd_test_record AUTO_INCREMENT = 1");
|
||
$pdo->exec("ALTER TABLE DGZXY_asd_test_detail AUTO_INCREMENT = 1");
|
||
|
||
header('Location: ' . BASE_URL . '/Admin/asdTestData');
|
||
exit;
|
||
}
|
||
}
|
||
|
||
// 获取设备列表供前端使用
|
||
$devices = [];
|
||
try {
|
||
$deviceModel = new \app\models\AsdDevice();
|
||
$devices = $deviceModel->getAllActive();
|
||
} catch (\Throwable $e) {}
|
||
|
||
$this->assign('title', '批量删除测试数据');
|
||
$this->assign('user', $user);
|
||
$this->assign('devices', $devices);
|
||
$this->assign('activeMenu', 'asdTestData');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
/**
|
||
* 自动生成员工编号:ZXY + 三位流水号(如 ZXY001, ZXY002...)
|
||
* 查询数据库中 ZXY 开头的最大编号,+1 作为新编号
|
||
*/
|
||
private function generateEmpNo()
|
||
{
|
||
$model = new \core\base\Model();
|
||
// 循环生成,直到找到不重复的编号
|
||
for ($i = 0; $i < 999; $i++) {
|
||
$row = $model->query(
|
||
"SELECT emp_no FROM `DGZXY_employee` WHERE emp_no LIKE :prefix ORDER BY emp_no DESC LIMIT 1",
|
||
[':prefix' => 'ZXY%']
|
||
);
|
||
if (!empty($row) && isset($row[0]['emp_no'])) {
|
||
$lastNo = $row[0]['emp_no'];
|
||
if (preg_match('/\d+/', $lastNo, $matches)) {
|
||
$num = (int)$matches[0] + 1 + $i;
|
||
} else {
|
||
$num = 1 + $i;
|
||
}
|
||
} else {
|
||
$num = 1 + $i;
|
||
}
|
||
$emp_no = 'ZXY' . str_pad($num, 3, '0', STR_PAD_LEFT);
|
||
// 检查是否已存在(防止并发重复)
|
||
$exists = $model->query(
|
||
"SELECT id FROM `DGZXY_employee` WHERE emp_no = :no LIMIT 1",
|
||
[':no' => $emp_no]
|
||
);
|
||
if (empty($exists)) {
|
||
return $emp_no;
|
||
}
|
||
}
|
||
// 兜底:用时间戳生成
|
||
return 'ZXY' . date('His');
|
||
}
|
||
|
||
// ========== 昂盛达设备调试 ==========
|
||
|
||
/**
|
||
* 昂盛达设备调试页 — 测试链接状态、数据读写
|
||
* 仅超级管理员与管理员可访问
|
||
*/
|
||
public function asdDeviceDebug()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$pdo = \core\db\Db::pdo();
|
||
$devices = [];
|
||
$result = null;
|
||
$selDeviceId = intval($_POST['device_id'] ?? ($_GET['device_id'] ?? 0));
|
||
|
||
try {
|
||
// 动态探测设备表真实列,避免硬编码 group_name/device_code 等线上不存在的列导致 1054
|
||
$cols = [];
|
||
try {
|
||
$dc = $pdo->query("DESCRIBE DGZXY_asd_device");
|
||
if ($dc) {
|
||
foreach ($dc->fetchAll(\PDO::FETCH_ASSOC) as $c) {
|
||
$cols[] = $c['Field'];
|
||
}
|
||
}
|
||
} catch (\Throwable $e) { /* 探测失败走兜底 */ }
|
||
$has = function ($name) use ($cols) { return in_array($name, $cols, true); };
|
||
$selectCols = $has('device_name') ? 'id, device_name, device_ip, device_port, status' : '*';
|
||
$orderCols = [];
|
||
foreach (['group_name', 'device_code', 'device_name', 'id'] as $oc) {
|
||
if ($has($oc)) { $orderCols[] = $oc . ' ASC'; }
|
||
}
|
||
$orderSql = $orderCols ? ('ORDER BY ' . implode(', ', $orderCols)) : '';
|
||
|
||
// 关键字查找设备(按名称/IP/编号模糊匹配)
|
||
$keyword = trim($_POST['keyword'] ?? ($_GET['keyword'] ?? ''));
|
||
$whereSql = '';
|
||
$params = [];
|
||
if ($keyword !== '') {
|
||
$likeParts = [];
|
||
foreach (['device_name', 'device_ip', 'device_code', 'group_name'] as $kc) {
|
||
if ($has($kc)) { $likeParts[] = $kc . ' LIKE :kw'; }
|
||
}
|
||
if ($likeParts) {
|
||
$whereSql = 'WHERE ' . implode(' OR ', $likeParts);
|
||
$params[':kw'] = '%' . $keyword . '%';
|
||
}
|
||
}
|
||
|
||
$sql = "SELECT {$selectCols} FROM DGZXY_asd_device {$whereSql} {$orderSql}";
|
||
$stmt = $pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$devices = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
$action = $_POST['action'] ?? '';
|
||
|
||
if ($action === 'manualConnect') {
|
||
// 手动连接:直接用表单输入的 IP:端口,不依赖数据库里的记录
|
||
$mip = trim($_POST['manual_ip'] ?? '');
|
||
$mport = intval($_POST['manual_port'] ?? 502);
|
||
if ($mport <= 0) { $mport = 502; }
|
||
$result = $this->asdDebugTestLink([
|
||
'device_ip' => $mip,
|
||
'device_port' => $mport,
|
||
'device_name' => '手动连接(' . $mip . ':' . $mport . ')',
|
||
]);
|
||
} else {
|
||
$deviceId = intval($_POST['device_id'] ?? 0);
|
||
$device = null;
|
||
$ds = $pdo->prepare("SELECT * FROM DGZXY_asd_device WHERE id = :id");
|
||
$ds->execute([':id' => $deviceId]);
|
||
$device = $ds->fetch(\PDO::FETCH_ASSOC);
|
||
if (!$device) {
|
||
$result = ['error' => '设备不存在或已被删除(ID=' . $deviceId . ')'];
|
||
} elseif ($action === 'testLink') {
|
||
$result = $this->asdDebugTestLink($device);
|
||
} elseif ($action === 'readData') {
|
||
$result = $this->asdDebugReadData($device);
|
||
} elseif ($action === 'writeData') {
|
||
$result = $this->asdDebugWriteData($device);
|
||
} else {
|
||
$result = ['error' => '未知操作: ' . $action];
|
||
}
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 把隐藏异常转为可见提示,便于定位(调试页允许暴露具体错误)
|
||
$result = ['action' => 'error', 'error' => '调试页异常:' . $e->getMessage()];
|
||
}
|
||
|
||
$manualConnect = !empty($_POST['action']) && $_POST['action'] === 'manualConnect';
|
||
|
||
$this->assign('title', '昂盛达设备调试');
|
||
$this->assign('user', $user);
|
||
$this->assign('devices', $devices);
|
||
$this->assign('selDeviceId', $selDeviceId);
|
||
$this->assign('result', $result);
|
||
$this->assign('keyword', $keyword ?? '');
|
||
$this->assign('manualConnect', $manualConnect ?? false);
|
||
$this->assign('activeMenu', 'asdDeviceDebug');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
/**
|
||
* 测试设备 TCP 链接状态(端口取 device_port,缺省 502)
|
||
*/
|
||
protected function asdDebugTestLink($device)
|
||
{
|
||
$ip = $device['device_ip'] ?? '';
|
||
$port = !empty($device['device_port']) ? intval($device['device_port']) : 502;
|
||
$reachable = false;
|
||
$latency = 0;
|
||
$err = '';
|
||
if (empty($ip)) {
|
||
$err = '设备 IP 未设置,请在设备管理中填写 device_ip';
|
||
} elseif (!function_exists('fsockopen')) {
|
||
$err = 'PHP 未启用 fsockopen 扩展,无法测试网络连通性';
|
||
} else {
|
||
$start = microtime(true);
|
||
$errno = 0;
|
||
$errstr = '';
|
||
$fp = @fsockopen($ip, $port, $errno, $errstr, 3);
|
||
if ($fp) {
|
||
$reachable = true;
|
||
$latency = round((microtime(true) - $start) * 1000, 1);
|
||
fclose($fp);
|
||
} else {
|
||
$err = '连接失败: ' . $errstr . ' (errno=' . $errno . ')';
|
||
}
|
||
}
|
||
return [
|
||
'action' => 'testLink',
|
||
'device' => $device,
|
||
'ip' => $ip,
|
||
'port' => $port,
|
||
'reachable' => $reachable,
|
||
'latency' => $latency,
|
||
'error' => $err,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 读取设备相关数据(asd_test_record 按 device_code 过滤 + 各表计数)
|
||
*/
|
||
protected function asdDebugReadData($device)
|
||
{
|
||
$pdo = \core\db\Db::pdo();
|
||
$deviceCode = $device['device_code'] ?? '';
|
||
$rows = [];
|
||
try {
|
||
$stmt = $pdo->prepare("SELECT * FROM DGZXY_asd_test_record WHERE device_code = :dc ORDER BY id DESC LIMIT 10");
|
||
$stmt->execute([':dc' => $deviceCode]);
|
||
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||
} catch (\Throwable $e) {
|
||
// 读取异常交由视图提示
|
||
}
|
||
$counts = ['rec_cnt' => 0, 'data_cnt' => 0, 'dev_cnt' => 0];
|
||
try {
|
||
$c = $pdo->query("SELECT (SELECT COUNT(*) FROM DGZXY_asd_test_record) AS rec_cnt, (SELECT COUNT(*) FROM DGZXY_asd_test_data) AS data_cnt, (SELECT COUNT(*) FROM DGZXY_asd_device) AS dev_cnt")->fetch(\PDO::FETCH_ASSOC);
|
||
if ($c) {
|
||
$counts = $c;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// ignore
|
||
}
|
||
return [
|
||
'action' => 'readData',
|
||
'device' => $device,
|
||
'device_code' => $deviceCode,
|
||
'rows' => $rows,
|
||
'counts' => $counts,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 写入探针记录并回读后删除,验证数据读写链路
|
||
*/
|
||
protected function asdDebugWriteData($device)
|
||
{
|
||
$deviceCode = $device['device_code'] ?? '';
|
||
$probeCode = 'DEBUG_PROBE_' . date('YmdHis') . '_' . rand(100, 999);
|
||
$model = new \app\models\AsdTestData();
|
||
$writtenId = 0;
|
||
$readBack = null;
|
||
$readBackMatch = false;
|
||
$deleted = false;
|
||
$err = '';
|
||
try {
|
||
$writtenId = $model->addRecord([
|
||
'qr_code' => $probeCode,
|
||
'device_code' => $deviceCode,
|
||
'test_result' => 'PASS',
|
||
'test_date' => date('Y-m-d H:i:s'),
|
||
'remark' => 'ASD_DEBUG_PROBE',
|
||
]);
|
||
if ($writtenId) {
|
||
$readBack = $model->findById($writtenId);
|
||
$readBackMatch = !empty($readBack) && ($readBack['qr_code'] ?? '') === $probeCode;
|
||
$deleted = (bool) $model->deleteRecord($writtenId);
|
||
} else {
|
||
$err = '写入失败(未获取到插入 ID)';
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$err = '写入/回读异常: ' . $e->getMessage();
|
||
}
|
||
return [
|
||
'action' => 'writeData',
|
||
'device' => $device,
|
||
'device_code' => $deviceCode,
|
||
'probe_code' => $probeCode,
|
||
'written_id' => $writtenId,
|
||
'read_back' => $readBack,
|
||
'read_back_match'=> $readBackMatch,
|
||
'deleted' => $deleted,
|
||
'error' => $err,
|
||
];
|
||
}
|
||
|
||
// ========== 数据库升级/迁移 ==========
|
||
|
||
|
||
/**
|
||
* 数据库升级页面 — 仅超级管理员可访问
|
||
* 自动扫描 sql/ 目录下的 .sql 文件,支持一键执行
|
||
*/
|
||
public function databaseUpgrade()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$pdo = \core\db\Db::pdo();
|
||
|
||
// 确保迁移记录表存在
|
||
$this->ensureMigrationsTable($pdo);
|
||
|
||
$sqlDir = APP_PATH . 'sql';
|
||
$message = '';
|
||
$messageType = ''; // success / danger / warning
|
||
|
||
// 处理 POST 请求
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
$action = $_POST['action'] ?? '';
|
||
|
||
if ($action === 'execute' && !empty($_POST['file'])) {
|
||
// 执行单个 SQL 文件
|
||
$file = basename($_POST['file']);
|
||
$filepath = $sqlDir . '/' . $file;
|
||
|
||
if (!file_exists($filepath)) {
|
||
$message = "文件不存在: {$file}";
|
||
$messageType = 'danger';
|
||
} else {
|
||
$result = $this->runSqlFile($pdo, $filepath, $file);
|
||
$message = $result['message'];
|
||
$messageType = $result['success'] ? 'success' : 'danger';
|
||
}
|
||
} elseif ($action === 'execute_all') {
|
||
// 执行所有未执行的 SQL 文件
|
||
$files = $this->scanSqlFiles($sqlDir);
|
||
$pendingFiles = $this->filterPendingFiles($pdo, $files);
|
||
|
||
if (empty($pendingFiles)) {
|
||
$message = '所有迁移脚本已执行完毕,没有需要执行的脚本。';
|
||
$messageType = 'warning';
|
||
} else {
|
||
$successCount = 0;
|
||
$failCount = 0;
|
||
$msgs = [];
|
||
|
||
foreach ($pendingFiles as $file) {
|
||
$filepath = $sqlDir . '/' . $file;
|
||
$result = $this->runSqlFile($pdo, $filepath, $file);
|
||
if ($result['success']) {
|
||
$successCount++;
|
||
} else {
|
||
$failCount++;
|
||
$msgs[] = $result['message'];
|
||
}
|
||
}
|
||
|
||
$message = "执行完毕:成功 {$successCount} 个";
|
||
if ($failCount > 0) {
|
||
$message .= ",失败 {$failCount} 个";
|
||
$message .= "\n" . implode("\n", $msgs);
|
||
$messageType = 'warning';
|
||
} else {
|
||
$message .= ",所有脚本执行成功!";
|
||
$messageType = 'success';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 扫描 SQL 文件并检查执行状态
|
||
$allFiles = $this->scanSqlFiles($sqlDir);
|
||
$migratedMap = $this->getMigratedFiles($pdo);
|
||
$pendingCount = 0;
|
||
|
||
$fileList = [];
|
||
foreach ($allFiles as $file) {
|
||
$isMigrated = isset($migratedMap[$file]);
|
||
$info = $migratedMap[$file] ?? null;
|
||
if (!$isMigrated) $pendingCount++;
|
||
|
||
$fileList[] = [
|
||
'name' => $file,
|
||
'is_migrated' => $isMigrated,
|
||
'executed_at' => $info['executed_at'] ?? '',
|
||
'status' => $info['status'] ?? null,
|
||
'error_msg' => $info['error_msg'] ?? '',
|
||
'preview' => $this->getSqlPreview($sqlDir . '/' . $file),
|
||
];
|
||
}
|
||
|
||
$this->assign('title', '数据库升级');
|
||
$this->assign('user', $user);
|
||
$this->assign('fileList', $fileList);
|
||
$this->assign('pendingCount', $pendingCount);
|
||
$this->assign('message', $message);
|
||
$this->assign('messageType', $messageType);
|
||
$this->assign('activeMenu', 'databaseUpgrade');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
/**
|
||
* 执行单条 SQL 并立即释放结果集游标。
|
||
*
|
||
* 不能使用 PDO::exec():exec() 拿不到语句句柄,在“非缓冲查询”环境下会残留
|
||
* 未取走的结果集,导致后续 prepare/execute 报 2014
|
||
* (Cannot execute queries while other unbuffered queries are active)。
|
||
* 这里改用 prepare -> execute -> closeCursor,彻底释放连接。
|
||
*/
|
||
private function pdoExec($pdo, $sql, $params = [])
|
||
{
|
||
$stmt = $pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$stmt->closeCursor();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 幂等写入迁移记录。
|
||
*
|
||
* 使用 INSERT ... ON DUPLICATE KEY UPDATE:当同名文件(uk_filename 唯一约束)
|
||
* 已存在时,更新其状态/时间/错误信息,而不是抛出 1062 Duplicate entry。
|
||
* 这样即便之前有失败记录,重跑也不会因唯一约束冲突而报“执行失败”。
|
||
*/
|
||
private function recordMigration($pdo, $filename, $status, $errorMsg = '')
|
||
{
|
||
$this->pdoExec(
|
||
$pdo,
|
||
"INSERT INTO `DGZXY_migrations` (filename, executed_at, status, error_msg)
|
||
VALUES (:fn, NOW(), :st, :err)
|
||
ON DUPLICATE KEY UPDATE executed_at = NOW(), status = VALUES(status), error_msg = VALUES(error_msg)",
|
||
[':fn' => $filename, ':st' => $status, ':err' => $errorMsg]
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 判断 SQL 执行错误是否为「可忽略的幂等错误」。
|
||
*
|
||
* 在 SAFE 模式重复执行迁移时,以下错误是预期内的,不应算作失败:
|
||
* - 1060 Duplicate column name:ADD COLUMN 时列已存在
|
||
* - 1061 Duplicate key name:CREATE INDEX 时索引已存在
|
||
* - 1091 Can't DROP ... check that column/index exists:DROP 时对象已不存在
|
||
* - already exists:其他“已存在”类提示
|
||
*/
|
||
private function isIgnorableSqlError($msg)
|
||
{
|
||
$patterns = [
|
||
'Duplicate column name',
|
||
'Duplicate key name',
|
||
'already exists',
|
||
'1060',
|
||
'1061',
|
||
'1091',
|
||
];
|
||
foreach ($patterns as $p) {
|
||
if (stripos($msg, $p) !== false) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 确保 migrations 表存在
|
||
*/
|
||
private function ensureMigrationsTable($pdo)
|
||
{
|
||
$this->pdoExec($pdo, "CREATE TABLE IF NOT EXISTS `DGZXY_migrations` (
|
||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
`filename` VARCHAR(255) NOT NULL COMMENT 'SQL文件名',
|
||
`executed_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间',
|
||
`status` TINYINT DEFAULT 1 COMMENT '1=成功 0=失败',
|
||
`error_msg` TEXT COMMENT '错误信息',
|
||
UNIQUE KEY `uk_filename` (`filename`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='数据库迁移记录表'");
|
||
}
|
||
|
||
/**
|
||
* 扫描 sql 目录下的 .sql 文件
|
||
*/
|
||
private function scanSqlFiles($dir)
|
||
{
|
||
$files = [];
|
||
if (is_dir($dir)) {
|
||
$dh = opendir($dir);
|
||
while (($file = readdir($dh)) !== false) {
|
||
if (preg_match('/\.sql$/i', $file) && $file !== 'create_migrations.sql') {
|
||
$files[] = $file;
|
||
}
|
||
}
|
||
closedir($dh);
|
||
sort($files);
|
||
}
|
||
return $files;
|
||
}
|
||
|
||
/**
|
||
* 获取已迁移的文件列表
|
||
*/
|
||
private function getMigratedFiles($pdo)
|
||
{
|
||
$stmt = $pdo->prepare("SELECT filename, executed_at, status, error_msg FROM `DGZXY_migrations` ORDER BY id ASC");
|
||
$stmt->execute();
|
||
$rows = $stmt->fetchAll();
|
||
$stmt->closeCursor();
|
||
$map = [];
|
||
foreach ($rows as $row) {
|
||
$map[$row['filename']] = $row;
|
||
}
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* 过滤出未执行的文件
|
||
*/
|
||
private function filterPendingFiles($pdo, $files)
|
||
{
|
||
$migratedMap = $this->getMigratedFiles($pdo);
|
||
$pending = [];
|
||
foreach ($files as $file) {
|
||
if (!isset($migratedMap[$file])) {
|
||
$pending[] = $file;
|
||
}
|
||
}
|
||
return $pending;
|
||
}
|
||
|
||
/**
|
||
* 获取 SQL 文件预览(前 20 行)
|
||
*/
|
||
private function getSqlPreview($filepath)
|
||
{
|
||
if (!file_exists($filepath)) return '';
|
||
$lines = file($filepath);
|
||
$preview = '';
|
||
$maxLines = 20;
|
||
$count = 0;
|
||
foreach ($lines as $line) {
|
||
if ($count >= $maxLines) {
|
||
$preview .= '... (更多内容已省略)';
|
||
break;
|
||
}
|
||
// 跳过纯注释行(但保留有意义的行)
|
||
$trimmed = trim($line);
|
||
if ($trimmed === '' || preg_match('/^--/', $trimmed)) {
|
||
$preview .= htmlspecialchars($line);
|
||
continue;
|
||
}
|
||
$preview .= htmlspecialchars($line);
|
||
$count++;
|
||
}
|
||
return $preview;
|
||
}
|
||
|
||
/**
|
||
* 执行单个 SQL 文件
|
||
*
|
||
* 支持两种模式:
|
||
* 1. 普通模式(默认):所有语句在一个事务中执行,任一条失败则全部回滚
|
||
* 2. SAFE 模式(文件首行含 -- @SAFE 标记):每条语句独立执行,失败跳过继续
|
||
* 适用于幂等补丁(字段可能已存在、表可能已创建等场景)
|
||
*/
|
||
private function runSqlFile($pdo, $filepath, $filename)
|
||
{
|
||
try {
|
||
// 读取文件内容
|
||
$sqlContent = file_get_contents($filepath);
|
||
|
||
// 安全检查:禁止执行危险的数据库操作
|
||
$dangerousPatterns = [
|
||
'/\bDROP\s+DATABASE\b/i',
|
||
'/\bDROP\s+SCHEMA\b/i',
|
||
'/\bTRUNCATE\s+TABLE\b/i',
|
||
'/\bALTER\s+TABLE\s+\S+\s+DROP\s+DATABASE\b/i',
|
||
];
|
||
foreach ($dangerousPatterns as $pattern) {
|
||
if (preg_match($pattern, $sqlContent)) {
|
||
error_log("[databaseUpgrade] Blocked dangerous SQL in {$filename}: matched pattern");
|
||
return ['success' => false, 'message' => "{$filename} — 安全拦截:SQL 文件包含危险操作(DROP DATABASE / TRUNCATE),已被拒绝执行"];
|
||
}
|
||
}
|
||
|
||
if (empty(trim($sqlContent))) {
|
||
// 空文件,标记为已执行
|
||
$this->recordMigration($pdo, $filename, 1, '');
|
||
return ['success' => true, 'message' => "{$filename} — 空文件,已跳过"];
|
||
}
|
||
|
||
// 检测是否为 SAFE 模式(逐句独立执行,容错)
|
||
$isSafeMode = (strpos($sqlContent, '-- @SAFE') !== false);
|
||
|
||
// 按分号拆分 SQL 语句
|
||
$statements = $this->splitSqlStatements($sqlContent);
|
||
|
||
if ($isSafeMode) {
|
||
// SAFE 模式:逐句独立执行,失败跳过
|
||
$total = 0;
|
||
$success = 0;
|
||
$ignored = 0; // 可忽略的幂等错误(列/索引已存在等)
|
||
$realErrors = []; // 需要关注的真实错误
|
||
|
||
foreach ($statements as $stmt) {
|
||
$stmt = trim($stmt);
|
||
if (empty($stmt)) continue;
|
||
// 移除所有 -- 行注释后检查是否还有实质 SQL 内容
|
||
$cleanStmt = preg_replace('/^\s*--.*$/m', '', $stmt);
|
||
$cleanStmt = trim($cleanStmt);
|
||
if (empty($cleanStmt)) continue;
|
||
|
||
$total++;
|
||
try {
|
||
$this->pdoExec($pdo, $stmt);
|
||
$success++;
|
||
} catch (\PDOException $e) {
|
||
$em = $e->getMessage();
|
||
if ($this->isIgnorableSqlError($em)) {
|
||
// 列/索引已存在等幂等错误,SAFE 模式下可安全跳过
|
||
$ignored++;
|
||
} else {
|
||
$realErrors[] = "语句{$total}: " . substr($em, 0, 160);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 记录执行结果
|
||
if ($total === 0) {
|
||
$this->recordMigration($pdo, $filename, 1, '');
|
||
return ['success' => true, 'message' => "{$filename} — 空文件(SAFE模式),已跳过"];
|
||
}
|
||
|
||
// SAFE 模式下无真实错误即视为成功(跳过的都是预期内的幂等错误)
|
||
$status = empty($realErrors) ? 1 : 1;
|
||
$msg = "{$filename} — SAFE模式:{$success} 条执行成功";
|
||
if ($ignored > 0) {
|
||
$msg .= ",{$ignored} 条已存在(自动跳过)";
|
||
}
|
||
if (!empty($realErrors)) {
|
||
$msg .= ",【需关注 " . count($realErrors) . " 条】" . implode(" | ", $realErrors);
|
||
}
|
||
|
||
$this->recordMigration($pdo, $filename, $status, implode("; ", $realErrors));
|
||
|
||
return ['success' => true, 'message' => $msg];
|
||
}
|
||
|
||
// 普通模式:事务执行
|
||
$pdo->beginTransaction();
|
||
|
||
foreach ($statements as $stmt) {
|
||
$stmt = trim($stmt);
|
||
if (empty($stmt)) continue;
|
||
// 移除所有 -- 行注释后检查是否还有实质 SQL 内容
|
||
$cleanStmt = preg_replace('/^\s*--.*$/m', '', $stmt);
|
||
$cleanStmt = trim($cleanStmt);
|
||
if (empty($cleanStmt)) continue;
|
||
|
||
$this->pdoExec($pdo, $stmt);
|
||
}
|
||
|
||
$pdo->commit();
|
||
|
||
// 记录执行成功
|
||
$this->recordMigration($pdo, $filename, 1, '');
|
||
|
||
return ['success' => true, 'message' => "{$filename} — 执行成功!"];
|
||
} catch (\PDOException $e) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
|
||
$errorMsg = $e->getMessage();
|
||
|
||
// 尝试记录失败(幂等,重复执行时更新而非报错)
|
||
try {
|
||
$this->recordMigration($pdo, $filename, 0, $errorMsg);
|
||
} catch (\Exception $ignoreEx) {}
|
||
|
||
return ['success' => false, 'message' => "{$filename} — 执行失败: " . $errorMsg];
|
||
} catch (\Exception $e) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
|
||
$errorMsg = $e->getMessage();
|
||
try {
|
||
$this->recordMigration($pdo, $filename, 0, $errorMsg);
|
||
} catch (\Exception $ignoreEx) {}
|
||
|
||
return ['success' => false, 'message' => "{$filename} — 执行失败: " . $errorMsg];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 按分号拆分 SQL 语句(处理字符串中的分号)
|
||
*/
|
||
private function splitSqlStatements($sql)
|
||
{
|
||
$statements = [];
|
||
$current = '';
|
||
$inString = false;
|
||
$stringChar = '';
|
||
$len = strlen($sql);
|
||
|
||
for ($i = 0; $i < $len; $i++) {
|
||
$ch = $sql[$i];
|
||
|
||
// 处理字符串边界
|
||
if ($ch === "'" || $ch === '"') {
|
||
if (!$inString) {
|
||
$inString = true;
|
||
$stringChar = $ch;
|
||
} elseif ($ch === $stringChar) {
|
||
// 检查是否是转义引号
|
||
$escapeCount = 0;
|
||
$j = $i - 1;
|
||
while ($j >= 0 && $sql[$j] === '\\') {
|
||
$escapeCount++;
|
||
$j--;
|
||
}
|
||
if ($escapeCount % 2 === 0) {
|
||
$inString = false;
|
||
$stringChar = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 只在字符串外识别分号
|
||
if ($ch === ';' && !$inString) {
|
||
$statements[] = $current;
|
||
$current = '';
|
||
} else {
|
||
$current .= $ch;
|
||
}
|
||
}
|
||
|
||
// 最后一部分
|
||
if (trim($current) !== '') {
|
||
$statements[] = $current;
|
||
}
|
||
|
||
return $statements;
|
||
}
|
||
|
||
// ==================== 数据库备份与恢复 ====================
|
||
|
||
/**
|
||
* 数据库备份管理页面
|
||
*/
|
||
public function databaseBackup()
|
||
{
|
||
$this->checkLogin();
|
||
$user = $this->getCurrentUser();
|
||
|
||
if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) {
|
||
exit('无权限访问');
|
||
}
|
||
|
||
$backupDir = APP_PATH . 'backups';
|
||
$message = '';
|
||
$messageType = '';
|
||
|
||
// 处理操作
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$this->csrfVerify();
|
||
$action = $_POST['action'] ?? '';
|
||
|
||
if ($action === 'backup') {
|
||
// 执行备份
|
||
$result = $this->doBackup($backupDir);
|
||
$message = $result['message'];
|
||
$messageType = $result['success'] ? 'success' : 'danger';
|
||
} elseif ($action === 'restore' && !empty($_POST['file'])) {
|
||
// 执行恢复
|
||
$file = basename($_POST['file']);
|
||
$filepath = $backupDir . '/' . $file;
|
||
|
||
if (!file_exists($filepath)) {
|
||
$message = "备份文件不存在: {$file}";
|
||
$messageType = 'danger';
|
||
} else {
|
||
$result = $this->doRestore($filepath);
|
||
$message = $result['message'];
|
||
$messageType = $result['success'] ? 'success' : 'danger';
|
||
}
|
||
} elseif ($action === 'delete' && !empty($_POST['file'])) {
|
||
// 删除备份文件
|
||
$file = basename($_POST['file']);
|
||
$filepath = $backupDir . '/' . $file;
|
||
|
||
if (file_exists($filepath) && unlink($filepath)) {
|
||
$message = "备份文件 {$file} 已删除";
|
||
$messageType = 'success';
|
||
} else {
|
||
$message = "删除失败: {$file}";
|
||
$messageType = 'danger';
|
||
}
|
||
} elseif ($action === 'download' && !empty($_POST['file'])) {
|
||
// 下载备份文件
|
||
$file = basename($_POST['file']);
|
||
$filepath = $backupDir . '/' . $file;
|
||
|
||
if (file_exists($filepath)) {
|
||
header('Content-Type: application/octet-stream');
|
||
header('Content-Disposition: attachment; filename="' . $file . '"');
|
||
header('Content-Length: ' . filesize($filepath));
|
||
readfile($filepath);
|
||
exit;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 扫描备份文件列表
|
||
$backupFiles = [];
|
||
if (is_dir($backupDir)) {
|
||
$files = scandir($backupDir);
|
||
foreach ($files as $file) {
|
||
if ($file === '.' || $file === '..') continue;
|
||
$filepath = $backupDir . '/' . $file;
|
||
if (is_file($filepath) && pathinfo($file, PATHINFO_EXTENSION) === 'sql') {
|
||
$backupFiles[] = [
|
||
'name' => $file,
|
||
'size' => $this->formatFileSize(filesize($filepath)),
|
||
'size_bytes' => filesize($filepath),
|
||
'time' => date('Y-m-d H:i:s', filemtime($filepath)),
|
||
];
|
||
}
|
||
}
|
||
// 按时间倒序排列
|
||
usort($backupFiles, function ($a, $b) {
|
||
return strcmp($b['time'], $a['time']);
|
||
});
|
||
}
|
||
|
||
$this->assign('title', '数据库备份与恢复');
|
||
$this->assign('user', $user);
|
||
$this->assign('backupFiles', $backupFiles);
|
||
$this->assign('message', $message);
|
||
$this->assign('messageType', $messageType);
|
||
$this->assign('activeMenu', 'databaseBackup');
|
||
$this->assign('csrfToken', $this->csrfToken());
|
||
$this->render();
|
||
}
|
||
|
||
/**
|
||
* 执行数据库备份
|
||
*/
|
||
private function doBackup($backupDir)
|
||
{
|
||
try {
|
||
$dbHost = DB_HOST;
|
||
$dbName = DB_NAME;
|
||
$dbUser = DB_USER;
|
||
$dbPass = DB_PASS;
|
||
|
||
$filename = $dbName . '_' . date('Ymd_His') . '.sql';
|
||
$filepath = $backupDir . '/' . $filename;
|
||
|
||
// 方式1:尝试使用 mysqldump 命令行
|
||
$mysqldumpPath = $this->findMysqldump();
|
||
if ($mysqldumpPath) {
|
||
$command = sprintf(
|
||
'"%s" --host=%s --user=%s --password=%s --databases %s --add-drop-database --add-drop-table --default-character-set=utf8mb4 --skip-lock-tables --result-file="%s" 2>&1',
|
||
$mysqldumpPath,
|
||
escapeshellarg($dbHost),
|
||
escapeshellarg($dbUser),
|
||
escapeshellarg($dbPass),
|
||
escapeshellarg($dbName),
|
||
$filepath
|
||
);
|
||
exec($command, $output, $returnCode);
|
||
|
||
if ($returnCode === 0 && file_exists($filepath)) {
|
||
return [
|
||
'success' => true,
|
||
'message' => "备份成功!文件: {$filename} (" . $this->formatFileSize(filesize($filepath)) . ")"
|
||
];
|
||
}
|
||
// mysqldump 失败,记录错误信息用于调试,回退到 PHP 方式
|
||
$dumpError = implode("\n", $output);
|
||
}
|
||
|
||
// 方式2:纯 PHP 实现备份
|
||
return $this->phpBackup($backupDir, $filename, $filepath, $dbHost, $dbName, $dbUser, $dbPass);
|
||
|
||
} catch (\Exception $e) {
|
||
return ['success' => false, 'message' => '备份失败: ' . $e->getMessage()];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 查找 mysqldump 可执行文件路径
|
||
*/
|
||
private function findMysqldump()
|
||
{
|
||
// exec 被禁用时,直接返回 null,让 phpBackup() 接管
|
||
if (!function_exists('exec')) {
|
||
return null;
|
||
}
|
||
|
||
// 常见路径
|
||
$paths = [
|
||
'mysqldump', // 系统 PATH 中
|
||
'D:\phpEnv\mysql\bin\mysqldump.exe',
|
||
'D:\phpEnv\MySQL\bin\mysqldump.exe',
|
||
'C:\phpEnv\mysql\bin\mysqldump.exe',
|
||
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe',
|
||
'C:\Program Files\MySQL\MySQL Server 5.7\bin\mysqldump.exe',
|
||
'/usr/bin/mysqldump',
|
||
'/usr/local/mysql/bin/mysqldump',
|
||
];
|
||
|
||
foreach ($paths as $path) {
|
||
// 检查文件是否存在,或命令是否可用
|
||
// 用 @ 抑制 open_basedir 警告
|
||
if (@file_exists($path)) {
|
||
return $path;
|
||
}
|
||
// 对于纯命令名,用 where/which 检测
|
||
if (!str_contains($path, '/') && !str_contains($path, '\\')) {
|
||
$checkCmd = (PHP_OS_FAMILY === 'Windows') ? "where {$path} 2>nul" : "which {$path} 2>/dev/null";
|
||
exec($checkCmd, $out, $ret);
|
||
if ($ret === 0 && !empty($out)) {
|
||
return trim($out[0]);
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 纯 PHP 方式备份数据库
|
||
*/
|
||
private function phpBackup($backupDir, $filename, $filepath, $dbHost, $dbName, $dbUser, $dbPass)
|
||
{
|
||
$pdo = \core\db\Db::pdo();
|
||
|
||
// 获取数据库字符集
|
||
$stmt = $pdo->query("SELECT @@character_set_database AS charset");
|
||
$charset = $stmt->fetch()['charset'] ?? 'utf8mb4';
|
||
|
||
$sql = "-- MES 数据库备份\n";
|
||
$sql .= "-- 数据库: {$dbName}\n";
|
||
$sql .= "-- 备份时间: " . date('Y-m-d H:i:s') . "\n";
|
||
$sql .= "-- 字符集: {$charset}\n\n";
|
||
|
||
$sql .= "SET NAMES {$charset};\n";
|
||
$sql .= "SET FOREIGN_KEY_CHECKS = 0;\n\n";
|
||
|
||
// 获取所有表
|
||
$tables = $pdo->query("SHOW TABLES")->fetchAll(\PDO::FETCH_COLUMN);
|
||
|
||
foreach ($tables as $table) {
|
||
// 跳过 jp_ 前缀的表
|
||
if (strpos($table, 'jp_') === 0) continue;
|
||
|
||
// 表结构
|
||
$row = $pdo->query("SHOW CREATE TABLE `{$table}`")->fetch();
|
||
$sql .= "-- 表结构: {$table}\n";
|
||
$sql .= "DROP TABLE IF EXISTS `{$table}`;\n";
|
||
$sql .= $row['Create Table'] . ";\n\n";
|
||
|
||
// 表数据
|
||
$dataStmt = $pdo->query("SELECT * FROM `{$table}`");
|
||
$rows = $dataStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||
|
||
if (!empty($rows)) {
|
||
$sql .= "-- 表数据: {$table}\n";
|
||
$columns = array_keys($rows[0]);
|
||
$colList = '`' . implode('`, `', $columns) . '`';
|
||
|
||
// 分批插入,每批 500 行
|
||
$chunks = array_chunk($rows, 500);
|
||
foreach ($chunks as $chunk) {
|
||
$values = [];
|
||
foreach ($chunk as $row) {
|
||
$escaped = [];
|
||
foreach ($row as $val) {
|
||
if ($val === null) {
|
||
$escaped[] = 'NULL';
|
||
} else {
|
||
$escaped[] = $pdo->quote($val);
|
||
}
|
||
}
|
||
$values[] = '(' . implode(', ', $escaped) . ')';
|
||
}
|
||
$sql .= "INSERT INTO `{$table}` ({$colList}) VALUES\n";
|
||
$sql .= implode(",\n", $values) . ";\n";
|
||
}
|
||
$sql .= "\n";
|
||
}
|
||
}
|
||
|
||
$sql .= "SET FOREIGN_KEY_CHECKS = 1;\n";
|
||
|
||
if (file_put_contents($filepath, $sql)) {
|
||
return [
|
||
'success' => true,
|
||
'message' => "备份成功(PHP方式)!文件: {$filename} (" . $this->formatFileSize(filesize($filepath)) . ")"
|
||
];
|
||
}
|
||
|
||
return ['success' => false, 'message' => '备份失败:无法写入文件'];
|
||
}
|
||
|
||
/**
|
||
* 执行数据库恢复
|
||
*/
|
||
private function doRestore($filepath)
|
||
{
|
||
try {
|
||
$pdo = \core\db\Db::pdo();
|
||
$dbName = DB_NAME;
|
||
|
||
// 读取 SQL 文件
|
||
$sql = file_get_contents($filepath);
|
||
if (empty($sql)) {
|
||
return ['success' => false, 'message' => '备份文件为空'];
|
||
}
|
||
|
||
// 尝试方式1:使用 mysql 命令行恢复(更快、更可靠)
|
||
$mysqlPath = $this->findMysql();
|
||
if ($mysqlPath) {
|
||
$command = sprintf(
|
||
'"%s" --host=%s --user=%s --password=%s --default-character-set=utf8mb4 %s < "%s" 2>&1',
|
||
$mysqlPath,
|
||
escapeshellarg(DB_HOST),
|
||
escapeshellarg(DB_USER),
|
||
escapeshellarg(DB_PASS),
|
||
escapeshellarg($dbName),
|
||
$filepath
|
||
);
|
||
exec($command, $output, $returnCode);
|
||
|
||
if ($returnCode === 0) {
|
||
return ['success' => true, 'message' => '数据库恢复成功!(命令行方式)'];
|
||
}
|
||
$cliError = implode("\n", $output);
|
||
}
|
||
|
||
// 方式2:纯 PHP 逐条执行 SQL
|
||
return $this->phpRestore($sql);
|
||
|
||
} catch (\Exception $e) {
|
||
return ['success' => false, 'message' => '恢复失败: ' . $e->getMessage()];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 查找 mysql 客户端可执行文件路径
|
||
*/
|
||
private function findMysql()
|
||
{
|
||
// exec 被禁用时,直接返回 null,让 phpRestore() 接管
|
||
if (!function_exists('exec')) {
|
||
return null;
|
||
}
|
||
|
||
$paths = [
|
||
'mysql',
|
||
'D:\phpEnv\mysql\bin\mysql.exe',
|
||
'D:\phpEnv\MySQL\bin\mysql.exe',
|
||
'C:\phpEnv\mysql\bin\mysql.exe',
|
||
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
|
||
'C:\Program Files\MySQL\MySQL Server 5.7\bin\mysql.exe',
|
||
'/usr/bin/mysql',
|
||
'/usr/local/mysql/bin/mysql',
|
||
];
|
||
|
||
foreach ($paths as $path) {
|
||
// 用 @ 抑制 open_basedir 警告
|
||
if (@file_exists($path)) {
|
||
return $path;
|
||
}
|
||
if (!str_contains($path, '/') && !str_contains($path, '\\')) {
|
||
$checkCmd = (PHP_OS_FAMILY === 'Windows') ? "where {$path} 2>nul" : "which {$path} 2>/dev/null";
|
||
exec($checkCmd, $out, $ret);
|
||
if ($ret === 0 && !empty($out)) {
|
||
return trim($out[0]);
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 纯 PHP 方式恢复数据库
|
||
*/
|
||
private function phpRestore($sql)
|
||
{
|
||
$pdo = \core\db\Db::pdo();
|
||
|
||
// 分割 SQL 语句
|
||
$statements = $this->splitSqlStatements($sql);
|
||
|
||
$successCount = 0;
|
||
$errors = [];
|
||
|
||
foreach ($statements as $statement) {
|
||
$statement = trim($statement);
|
||
if (empty($statement)) continue;
|
||
|
||
// 跳过纯注释行
|
||
if (preg_match('/^--/', $statement)) continue;
|
||
|
||
try {
|
||
$pdo->exec($statement);
|
||
$successCount++;
|
||
} catch (\PDOException $e) {
|
||
$errors[] = "错误: " . $e->getMessage() . "\nSQL: " . substr($statement, 0, 200);
|
||
}
|
||
}
|
||
|
||
if (empty($errors)) {
|
||
return [
|
||
'success' => true,
|
||
'message' => "数据库恢复成功!(PHP方式)共执行 {$successCount} 条语句"
|
||
];
|
||
}
|
||
|
||
return [
|
||
'success' => false,
|
||
'message' => "恢复过程中出现 " . count($errors) . " 个错误(已执行 {$successCount} 条):\n" . implode("\n", array_slice($errors, 0, 5))
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小
|
||
*/
|
||
private function formatFileSize($bytes)
|
||
{
|
||
$units = ['B', 'KB', 'MB', 'GB'];
|
||
$i = 0;
|
||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||
$bytes /= 1024;
|
||
$i++;
|
||
}
|
||
return round($bytes, 2) . ' ' . $units[$i];
|
||
}
|
||
} |