Initial commit

This commit is contained in:
18338781771
2025-05-14 13:19:23 +08:00
commit 1ab607f65a
313 changed files with 27595 additions and 0 deletions
@@ -0,0 +1,74 @@
<?php
namespace app\mzl\modules\jwt\middleware;
use ReflectionClass;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use app\mzl\modules\jwt\service\JwtService;
class JwtMiddleware implements MiddlewareInterface
{
protected $jwtService;
public function __construct()
{
$this->jwtService = new JwtService();
}
public function process(Request $request, callable $handler): Response
{
// 通过反射获取控制器
$controller = new ReflectionClass($request->controller);
$noNeedJwt = $controller->getDefaultProperties()['noNeedJwt'] ?? [];
// 除外
if (in_array($request->action, $noNeedJwt)) {
return $handler($request);
} else {
try {
$authHeader = $request->header('Authorization');
if (!$authHeader || !preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
$body = [
'status' => 'error',
'code' => 401,
'message' => '未授权',
'data' => [],
];
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
}
$token = $matches[1];
$request->user = $this->jwtService->verifyToken($token);
// 验证权限 -- start
// $user = \app\mzl\modules\permission\model\User::find($request->user->user_id);
// if (!$user || !$user->hasPermission($request->path())) {
// $body = [
// 'status' => 'error',
// 'code' => 403,
// 'message' => '无权限访问',
// 'data' => [],
// ];
// return response(json_encode($body, JSON_UNESCAPED_UNICODE), 403, ['Content-Type' => 'application/json']);
// }
// 验证权限 -- end
} catch (\Throwable $e) {
$body = [
'status' => 'error',
'code' => 401,
'message' => $e->getMessage(),
'data' => [],
];
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
}
return $handler($request);
}
}
}
@@ -0,0 +1,70 @@
<?php
namespace app\mzl\modules\jwt\service;
use Firebase\JWT\JWT as FirebaseJWT;
use Firebase\JWT\Key;
use support\exception\BusinessException;
class JwtService
{
protected $secretKey;
protected $algorithm;
protected $issuer;
protected $expiration;
public function __construct()
{
$this->secretKey = getenv('JWT_SECRET_KEY') ?: 'default_secret_key';
$this->algorithm = getenv('JWT_ALGORITHM') ?: 'HS256';
$this->issuer = getenv('JWT_ISSUER') ?: 'domain.com';
$this->expiration = (int)(getenv('JWT_EXPIRATION') ?: 3600);
}
/**
* 生成 JWT 令牌
*
* @param array $payload
* @return string
* @throws BusinessException
*/
public function generateToken(array $payload): string
{
$defaultPayload = [
'iss' => $this->issuer,
'iat' => time(),
'nbf' => time(),
'exp' => time() + $this->expiration,
];
$payload = array_merge($defaultPayload, $payload);
try {
return FirebaseJWT::encode($payload, $this->secretKey, $this->algorithm);
} catch (\Exception $e) {
throw new BusinessException("令牌生成失败:" . $e->getMessage(), 500);
}
}
/**
* 验证 JWT 令牌
*
* @param string $token
* @return object
* @throws BusinessException
*/
public function verifyToken(string $token)
{
try {
return FirebaseJWT::decode($token, new Key($this->secretKey, $this->algorithm));
} catch (\Firebase\JWT\ExpiredException $e) {
throw new BusinessException('令牌已过期', 401);
} catch (\Firebase\JWT\SignatureInvalidException $e) {
throw new BusinessException('无效的令牌签名', 401);
} catch (\Firebase\JWT\BeforeValidException $e) {
throw new BusinessException('令牌尚未生效', 401);
} catch (\Exception $e) {
throw new BusinessException('令牌验证失败:' . $e->getMessage(), 400);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
CREATE TABLE `roles` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '角色标识',
`label` varchar(100) DEFAULT NULL COMMENT '角色名称',
`created_at` int(11) unsigned DEFAULT '0',
`updated_at` int(11) unsigned DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色';
CREATE TABLE `permissions` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '权限标识',
`label` varchar(100) DEFAULT NULL COMMENT '权限名称',
`created_at` int(11) unsigned DEFAULT '0',
`updated_at` int(11) unsigned DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限';
CREATE TABLE `role_user` (
`user_id` int(11) NOT NULL,
`role_id` int(11) NOT NULL,
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色_用户';
CREATE TABLE `permission_role` (
`role_id` int(11) NOT NULL,
`permission_id` int(11) NOT NULL,
PRIMARY KEY (`role_id`,`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限_角色';
@@ -0,0 +1,61 @@
<?php
namespace app\mzl\modules\permission\model;
use support\Model;
/**
* Permission
*/
class Permission extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'permissions';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
/**
* 模型的数据字段的保存格式。
*
* @var string
*/
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'name', 'label', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
public function roles()
{
return $this->belongsToMany(Role::class, 'permission_role');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace app\mzl\modules\permission\model;
use support\Model;
/**
* Role
*/
class Role extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'roles';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
/**
* 模型的数据字段的保存格式。
*
* @var string
*/
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'name', 'label', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
public function permissions()
{
return $this->belongsToMany(Permission::class, 'permission_role');
}
public function users()
{
return $this->belongsToMany(User::class, 'role_user');
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace app\mzl\modules\permission\model;
use support\Model;
use support\Redis;
/**
* User
*/
class User extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'wzh_user';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
/**
* 模型的数据字段的保存格式。
*
* @var string
*/
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'password', 'realname', 'idcard', 'gzh_openid', 'identity', 'role_id', 'sex', 'phone', 'created_at', 'updated_at', 'deleted_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
public function roles()
{
return $this->belongsToMany(Role::class, 'role_user');
}
public function hasPermission2(string $permission): bool
{
// 加载角色和每个角色的权限
$roles = $this->roles()->with('permissions')->get();
// 判断是否有某个权限
return $roles->pluck('permissions')->flatten()->pluck('name')->contains($permission);
}
public function hasPermission(string $permission, int $ttl = 24 * 60 * 60): bool
{
$cacheKey = 'user_permissions_' . $this->id;
$permissions = Redis::get($cacheKey);
if (!$permissions) {
$roles = $this->roles()->with('permissions')->get();
$permissions = $roles->pluck('permissions')->flatten()->pluck('name')->toArray();
Redis::setEx($cacheKey, $ttl, json_encode($permissions));
} else {
$permissions = json_decode($permissions, true);
}
return in_array($permission, $permissions);
}
}
@@ -0,0 +1,33 @@
<?php
/**
* Excel 导入配置
*/
return [
'student' => [
'column_map' => [
'A' => 'index',
'B' => 'company',
'C' => 'idcard',
'D' => 'name',
'E' => 'gender',
'F' => 'nation',
'G' => 'education',
'H' => 'position',
'I' => 'train_type',
'J' => 'exam_type',
'K' => 'phone',
'L' => 'address',
'M' => 'erp_code',
]
],
'user' => [
'column_map' => [
'A' => 'realname',
'B' => 'sex',
'C' => 'phone',
'D' => 'idcard',
'E' => 'identity'
]
]
];
@@ -0,0 +1,606 @@
<?php
namespace app\mzl\modules\phpoffice\service;
use support\Db;
// use support\Log;
use support\exception\BusinessException;
use app\api\model\PracticeItemsModel;
use app\api\model\PracticeItemsScoreModel;
use app\api\model\PracticeModel;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
// use PhpOffice\PhpSpreadsheet\Style\Fill;
// use PhpOffice\PhpSpreadsheet\Shared\Drawing as SharedDrawing;
// use PhpOffice\PhpSpreadsheet\Writer\Pdf\Dompdf as PdfWriter;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Writer\Pdf\Mpdf as PdfWriter;
use Mpdf\Config\ConfigVariables;
use Mpdf\Config\FontVariables;
use PhpOffice\PhpSpreadsheet\Writer\Pdf\Tcpdf as TcPdfWriter;
class ExcelService
{
// 配置
protected $config = [];
public function __construct()
{
$this->config = getConfig('/mzl/modules/phpoffice/config/excel_import.php');
}
/**
* 导入Excel数据
*/
public function importToData(string $template_key, string $filePath): array
{
$config = $this->config[$template_key] ?? null;
if (!$config) {
throw new BusinessException("未找到模板配置:$template_key");
}
$spreadsheet = IOFactory::load($filePath);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, true);
$callback = $this->callback($template_key) ?? null;
return $this->toData($rows, $config['column_map'], $callback);
}
/**
* 条件回调
*/
public function callback(string $template_key)
{
switch ($template_key) {
case 'student':
$callback = function ($row, $i) {
return $i < 3 || empty($row['C']);
};
break;
case 'user':
$callback = function ($row, $i) {
return $i < 2 || empty($row['D']);
};
break;
}
return $callback;
}
/**
* 获取数据
*/
public function toData(array $rows = [], array $columnMap = [], $callback = null)
{
$data = [];
foreach ($rows as $i => $row) {
if ($callback && $callback($row, $i)) {
continue;
}
$item = [];
foreach ($columnMap as $excelKey => $dbField) {
$item[$dbField] = trim((string)($row[$excelKey] ?? ''));
}
$data[] = $item;
}
return $data;
}
/**
* 导入practice
*/
public function importPractice(string $filePath)
{
Db::beginTransaction();
try {
$spreadsheet = IOFactory::load($filePath);
$sheet = $spreadsheet->getSheet(0);
$rows = $sheet->toArray();
// 解析标题行
$titleRow = trim($rows[0][0]);
preg_match('/(.*?)\s+([A-Za-z0-9-]+\/[A-Za-z0-9]+)$/', $titleRow, $matches);
$practice = PracticeModel::create([
'sn' => generateRandomSN(),
'title' => $matches[1] ?? '未知标题',
'code' => $matches[2] ?? '未编号'
]);
$currentItem = null;
foreach ($rows as $index => $row) {
// 跳过表头前两行(标题行+列头行)
if ($index < 2 || empty(array_filter($row))) continue;
// 处理主检查项
if (!empty($row[0]) && is_numeric($row[0])) {
$currentItem = PracticeItemsModel::create([
'practice_id' => $practice->id,
'index' => $row[0],
'exam_content' => $row[1],
'scoring_element' => $row[2],
'score_allocation' => $row[3]
]);
}
// 处理评分标准
if ($currentItem && !empty($row[4])) {
PracticeItemsScoreModel::create([
'practice_item_id' => $currentItem->id,
'scoring_criteria' => $row[4],
'max_deduction' => $row[5],
'single_deduction' => $row[6]
]);
}
}
DB::commit();
return true;
} catch (\Throwable $e) {
DB::rollBack();
return false;
}
}
// ====== ====== ====== 公共方法 ====== ====== ======
private function setColumnWidths($sheet, array $widths)
{
foreach ($widths as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
}
private function setRowHeights($sheet, array $heights)
{
foreach ($heights as $row => $height) {
$sheet->getRowDimension($row)->setRowHeight($height);
}
}
private function centerAlignAllCells($sheet)
{
$maxRow = $sheet->getHighestRow();
$maxCol = $sheet->getHighestColumn();
$sheet->getStyle("A1:{$maxCol}{$maxRow}")->applyFromArray([
'alignment' => [
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
]
]);
}
private function setGlobalFont($sheet, $start = null, $end = null, $name = '宋体', $size = 10)
{
if ($start && $end) {
$fw = "{$start}:{$end}";
} else {
$maxRow = $sheet->getHighestRow();
$maxCol = $sheet->getHighestColumn();
$fw = "A1:{$maxCol}{$maxRow}";
}
$sheet->getStyle($fw)->applyFromArray([
'font' => [
'name' => $name,
'size' => $size,
],
]);
}
// ====== ====== ====== 公共方法 ====== ====== ======
/**
* 导出 班级总结
*/
public function exportClassSummary2(array $data, string $outputPath = '班级总结', ?string $signatureImage = null)
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// ====== 1. 标题 & 基础信息 ======
$sheet->setCellValue('A1', $data['title'] ?? 'XX(班级名称)班级信息');
// 标题行立即合并
$sheet->mergeCells('A1:C1');
$basicInfo = [
['开班地点', 'location'],
['开班类型', 'type'],
['培训项目', 'program'],
['授课老师', 'teacher'],
['培训时间', 'time'],
['参培学员', 'students'],
['合格人员', 'passed'],
['通过率', 'pass_rate'],
];
$row = 2;
foreach ($basicInfo as $info) {
[$label, $key] = $info;
$sheet->setCellValue("A{$row}", $label);
$sheet->setCellValue("B{$row}", $data[$key] ?? '');
$sheet->mergeCells("B{$row}:C{$row}");
$row++;
}
// ====== 2. 理论考试 ======
if (! empty($data['theory_exams'])) {
foreach ($data['theory_exams'] as $idx => $exam) {
$subject = $exam['subject'] ?? "科目" . ($idx + 1);
$fields = [
'考试时间' => 'time',
'考试地点' => 'location',
'主监考' => 'main_invigilator',
'监考人员' => 'invigilators',
'不及格人数' => 'fail_count',
'合格率' => 'pass_rate',
];
$blockSize = count($fields);
$startRow = $row;
$endRow = $row + $blockSize - 1;
$sheet->mergeCells("A{$startRow}:A{$endRow}");
$sheet->setCellValue("A{$startRow}", "理论考试信息({$subject})");
$r = $startRow;
foreach ($fields as $label => $key) {
$sheet->setCellValue("B{$r}", $label);
$sheet->setCellValue("C{$r}", $exam[$key] ?? '');
$r++;
}
$row = $endRow + 1;
}
}
// ====== 3. 实操考试 ======
if (! empty($data['practice_exams'])) {
foreach ($data['practice_exams'] as $idx => $exam) {
$subject = $exam['subject'] ?? "实操" . ($idx + 1);
$fields = [
'考试科目' => 'subject',
'考试时间' => 'time',
'考试地点' => 'location',
'不及格人数' => 'fail_count',
'合格率' => 'pass_rate',
];
$blockSize = count($fields);
$startRow = $row;
$endRow = $row + $blockSize - 1;
$sheet->mergeCells("A{$startRow}:A{$endRow}");
$sheet->setCellValue("A{$startRow}", "实操考试信息({$subject})");
$r = $startRow;
foreach ($fields as $label => $key) {
$sheet->setCellValue("B{$r}", $label);
$sheet->setCellValue("C{$r}", $exam[$key] ?? '');
$r++;
}
$row = $endRow + 1;
}
}
// ====== 5. 样式:合并、边框、对齐、行高、字体 ======
$lastRow = $row;
// 全区域合并边框
$sheet->getStyle("A1:C{$lastRow}")->applyFromArray([
'borders' => ['allBorders'=>['borderStyle'=>Border::BORDER_THIN]],
]);
// 对齐
$maxCol = $sheet->getHighestColumn();
$sheet->getStyle("A1:{$maxCol}{$lastRow}")->applyFromArray([
'alignment'=>[
'horizontal'=>\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
'vertical' =>\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
]
]);
// 字体
// 标题行
$sheet->getStyle('A1')->applyFromArray([
'font'=>['name'=>'黑体','size'=>22],
]);
// 正文字体
$sheet->getStyle("A2:C{$lastRow}")->applyFromArray([
'font'=>['name'=>'宋体','size'=>10],
]);
// 班主任行单独字号 12
$sheet->getStyle("A{$row}:C{$row}")->applyFromArray([
'font'=>['name'=>'宋体','size'=>12],
]);
// 行高
$sheet->getRowDimension(1)->setRowHeight(36);
for ($r = 2; $r < $lastRow; $r++) {
$sheet->getRowDimension($r)->setRowHeight(21.75);
}
$sheet->getRowDimension($lastRow)->setRowHeight(54);
// 列宽
$sheet->getColumnDimension('A')->setWidth(30);
$sheet->getColumnDimension('B')->setWidth(30);
$sheet->getColumnDimension('C')->setWidth(24);
// ====== 4. 班主任签名(水平+垂直居中) ======
$sheet->setCellValue("A{$row}", '班主任');
// 合并 B、C 列
$sheet->mergeCells("B{$row}:C{$row}");
if ($signatureImage && file_exists($signatureImage)) {
$drawing = new Drawing();
$drawing->setPath($signatureImage);
// 先指定高度,等比例缩放宽度
$drawing->setHeight(40);
$drawing->setResizeProportional(true);
// 锚定到 B{$row} 单元格
$drawing->setCoordinates("B{$row}");
// 必须先 add 到工作表,才能正确计算后续的 getWidth()/getHeight()
$drawing->setWorksheet($sheet);
// —— 计算区域像素尺寸 ——
// 列宽单位转像素近似:1 单位 ≈ 7px
// 行高点(pt)转像素近似:1pt ≈ 96/72 px
$pxPerWidth = 7;
$pxPerPoint = 96 / 72;
$widthB = $sheet->getColumnDimension('B')->getWidth();
$widthC = $sheet->getColumnDimension('C')->getWidth();
$totalWidthPx = ($widthB + $widthC) * $pxPerWidth;
$rowHeightPt = $sheet->getRowDimension($row)->getRowHeight();
$totalHeightPx = $rowHeightPt * $pxPerPoint;
// 这时 getWidth()/getHeight() 才会返回渲染后的像素值
$imageWidthPx = $drawing->getWidth();
$imageHeightPx = $drawing->getHeight();
// 计算居中偏移
$offsetX = max(0, ($totalWidthPx - $imageWidthPx) / 2);
$offsetY = max(0, ($totalHeightPx - $imageHeightPx) / 2);
$drawing->setOffsetX($offsetX);
$drawing->setOffsetY($offsetY);
} else {
$sheet->setCellValue("B{$row}", $data['class_teacher'] ?? '');
}
// 最后保存
$writer = new Xlsx($spreadsheet);
$writer->save($outputPath);
}
public function exportClassSummary(array $data, string $outputPath = '班级总结', ?string $signatureImage = null)
{
$spreadsheet = $this->buildClassSummarySpreadsheet($data, $signatureImage);
$writer = new Xlsx($spreadsheet);
$writer->save($outputPath);
}
/**
* 构建班级总结的 Spreadsheet 对象(复用给 XLSX 和 PDF)
*
* @param array $data
* @param string|null $signatureImage
* @return Spreadsheet
*/
protected function buildClassSummarySpreadsheet(array $data, ?string $signatureImage = null): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
//
// 1. 标题 & 基础信息
//
$sheet->setCellValue('A1', $data['title'] ?? 'XX(班级名称)班级信息');
$sheet->mergeCells('A1:C1');
$basicInfo = [
['开班地点', 'location'],
['开班类型', 'type'],
['培训项目', 'program'],
['授课老师', 'teacher'],
['培训时间', 'time'],
['参培学员', 'students'],
['合格人员', 'passed'],
['通过率', 'pass_rate'],
];
$row = 2;
foreach ($basicInfo as $info) {
[$label, $key] = $info;
$sheet->setCellValue("A{$row}", $label);
$sheet->setCellValue("B{$row}", $data[$key] ?? '');
$sheet->mergeCells("B{$row}:C{$row}");
$row++;
}
//
// 2. 理论考试(theory_exams)
//
if (! empty($data['theory_exams']) && is_array($data['theory_exams'])) {
foreach ($data['theory_exams'] as $idx => $exam) {
$subject = $exam['subject'] ?? '科目' . ($idx + 1);
$fields = [
'考试时间' => 'time',
'考试地点' => 'location',
'主监考' => 'main_invigilator',
'监考人员' => 'invigilators',
'不及格人数' => 'fail_count',
'合格率' => 'pass_rate',
];
$blockSize = count($fields);
$startRow = $row;
$endRow = $row + $blockSize - 1;
$sheet->mergeCells("A{$startRow}:A{$endRow}");
$sheet->setCellValue("A{$startRow}", "理论考试信息({$subject})");
$r = $startRow;
foreach ($fields as $label => $key) {
$sheet->setCellValue("B{$r}", $label);
$sheet->setCellValue("C{$r}", $exam[$key] ?? '');
$r++;
}
$row = $endRow + 1;
}
}
//
// 3. 实操考试(practice_exams)
//
if (! empty($data['practice_exams']) && is_array($data['practice_exams'])) {
foreach ($data['practice_exams'] as $idx => $exam) {
$subject = $exam['subject'] ?? '实操' . ($idx + 1);
$fields = [
'考试科目' => 'subject',
'考试时间' => 'time',
'考试地点' => 'location',
'不及格人数' => 'fail_count',
'合格率' => 'pass_rate',
];
$blockSize = count($fields);
$startRow = $row;
$endRow = $row + $blockSize - 1;
$sheet->mergeCells("A{$startRow}:A{$endRow}");
$sheet->setCellValue("A{$startRow}", "实操考试信息({$subject})");
$r = $startRow;
foreach ($fields as $label => $key) {
$sheet->setCellValue("B{$r}", $label);
$sheet->setCellValue("C{$r}", $exam[$key] ?? '');
$r++;
}
$row = $endRow + 1;
}
}
//
// 4. 样式预设:列宽、行高、边框、对齐、字体
//
// 列宽
$sheet->getColumnDimension('A')->setWidth(30);
$sheet->getColumnDimension('B')->setWidth(30);
$sheet->getColumnDimension('C')->setWidth(24);
// 当前签名行号
$lastRow = $row;
// 行高
$sheet->getRowDimension(1)->setRowHeight(36);
for ($r = 2; $r < $lastRow; $r++) {
$sheet->getRowDimension($r)->setRowHeight(21.75);
}
$sheet->getRowDimension($lastRow)->setRowHeight(54);
// 边框 + 对齐 + 正文字体
$sheet->getStyle("A1:C{$lastRow}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
'font' => ['name' => '宋体', 'size' => 10],
]);
// 标题行字体
$sheet->getStyle('A1')->applyFromArray([
'font' => ['name' => '黑体', 'size' => 22],
]);
//
// 5. 班主任签名 & 图片居中
//
$sheet->setCellValue("A{$lastRow}", '班主任');
$sheet->mergeCells("B{$lastRow}:C{$lastRow}");
if ($signatureImage && file_exists($signatureImage)) {
$drawing = new Drawing();
$drawing->setPath($signatureImage);
$drawing->setResizeProportional(true);
$drawing->setHeight(40);
$drawing->setCoordinates("B{$lastRow}");
$drawing->setWorksheet($sheet);
// 近似像素转换系数
$pxPerColWidth = 7;
$pxPerPoint = 96 / 72;
// 区域宽度 & 高度
$wB = $sheet->getColumnDimension('B')->getWidth();
$wC = $sheet->getColumnDimension('C')->getWidth();
$areaW = ($wB + $wC) * $pxPerColWidth;
$hPt = $sheet->getRowDimension($lastRow)->getRowHeight();
$areaH = $hPt * $pxPerPoint;
// 图片渲染后尺寸
$imgW = $drawing->getWidth();
$imgH = $drawing->getHeight();
// 计算偏移并应用
$drawing->setOffsetX(max(0, ($areaW - $imgW) / 2));
$drawing->setOffsetY(max(0, ($areaH - $imgH) / 2));
} else {
// 文字班主任行单独字号
$sheet->getStyle("B{$lastRow}:C{$lastRow}")->applyFromArray([
'font' => ['name' => '宋体', 'size' => 12],
]);
$sheet->setCellValue("B{$lastRow}", $data['class_teacher'] ?? '');
}
return $spreadsheet;
}
/**
* 导出 PDF 文件
*
* @param array $data
* @param string $pdfPath
* @param string|null $signatureImage
*/
public function exportClassSummaryPdf(array $data, string $pdfPath, ?string $signatureImage = null)
{
// 1. 复用生成表格的逻辑
$spreadsheet = $this->buildClassSummarySpreadsheet($data, $signatureImage);
// // 2) mPDF 默认目录和字体数据
// $configVars = new ConfigVariables();
// $fontDirs = $configVars->getDefaults()['fontDir'];
// $fontVars = new FontVariables();
// $fontData = $fontVars->getDefaults()['fontdata'];
// $myFontDir = base_path('resources/fonts');
// $mpdfConfig = [
// 'mode' => 'utf-8',
// 'format' => 'A4',
// 'fontDir' => array_merge($fontDirs, [$myFontDir]),
// 'fontdata' => array_merge($fontData, [
// 'sourcehansc' => [
// 'R' => 'SourceHanSans-Regular.ttf',
// 'B' => 'SourceHanSans-Bold.ttf',
// ]
// ]),
// 'default_font'=> 'sourcehansc',
// 'autoScriptToLang'=> true,
// 'autoLangToFont' => true,
// ];
// // 2. 用 mPDF Writer 输出 PDF
// $pdfWriter = new PdfWriter($spreadsheet, $mpdfConfig);
// $pdfWriter->setFont('宋体');
// $pdfWriter->setPaperSize(PageSetup::PAPERSIZE_A4);
// $pdfWriter->setOrientation(PageSetup::ORIENTATION_PORTRAIT);
// $pdfWriter->save($pdfPath);
// 2. 用 TCPDF Writer 输出 PDF
$tcpdfWriter = new TcPdfWriter($spreadsheet);
$tcpdfWriter->setFont('cid0cs');
$tcpdfWriter->setPaperSize(PageSetup::PAPERSIZE_A4);
$tcpdfWriter->setOrientation(PageSetup::ORIENTATION_PORTRAIT);
$tcpdfWriter->save($pdfPath);
}
}
@@ -0,0 +1,38 @@
<?php
return [
// 定义所有需要多语言支持的实体
'entities' => [
'vehicle' => [
'translation_model' => \app\console\model\VehicleDataTranslationModel::class,
'foreign_key' => 'vehicle_id',
],
'energy' => [
'translation_model' => \app\console\model\EnergyTranslationModel::class,
'foreign_key' => 'energy_id',
],
'type' => [
'translation_model' => \app\console\model\TypesTranslationModel::class,
'foreign_key' => 'type_id',
],
'model' => [
'translation_model' => \app\console\model\ModelTranslationModel::class,
'foreign_key' => 'model_id',
],
'brand' => [
'translation_model' => \app\console\model\BrandTranslationModel::class,
'foreign_key' => 'brand_id',
],
'agreement' => [
'translation_model' => \app\console\model\AgreementTranslationModel::class,
'foreign_key' => 'agreement_id',
],
'location' => [
'translation_model' => \app\console\model\LocationTranslationModel::class,
'foreign_key' => 'location_id',
]
],
// 默认支持的目标语种列表
'locales' => ['zh','en','ru','ar'],
];
@@ -0,0 +1,64 @@
<?php
namespace app\mzl\modules\translation\jobs;
use support\Log;
use app\mzl\modules\translation\service\TranslatorClient;
class TranslationJob
{
protected $config = [];
protected array $payload;
public function __construct(array $payload)
{
$this->config = getConfig('/mzl/modules/translation/config/trans.php');
$this->payload = $payload;
}
public function handle(): void
{
$cfgAll = $this->config['entities'];
$pt = $this->payload;
$type = $pt['entityType'];
$id = $pt['entityId'];
$locale = $pt['locale'];
$sourceLocale = $pt['sourceLocale'];
$sourceFields = $pt['sourceFields'];
$clientFields = $pt['clientFields'];
if (! isset($cfgAll[$type])) {
Log::warning("TranslationJob: 未配置实体 {$type}");
return;
}
$cfg = $cfgAll[$type];
$modelClass = $cfg['translation_model'];
$foreignKey = $cfg['foreign_key'];
// 决定采用前端提供、源语种还是第三方翻译
if (! empty($clientFields)) {
$fields = $clientFields;
} elseif ($locale === $sourceLocale) {
$fields = $sourceFields;
} else {
$translator = new TranslatorClient();
$fields = [];
foreach ($sourceFields as $key => $text) {
$fields[$key] = $translator->translate($text, $sourceLocale, $locale);
}
}
// 写库
try {
$modelClass::updateOrCreate(
[
$foreignKey => $id,
'locale' => $locale,
],
array_merge($fields, ['locale' => $locale])
);
} catch (\Throwable $e) {
Log::error("TranslationJob 保存失败: {$type}#{$id} locale={$locale}: ".$e->getMessage());
}
}
}
@@ -0,0 +1,50 @@
<?php
namespace app\mzl\modules\translation\queue;
use support\Log;
use Webman\RedisQueue\Consumer;
use app\mzl\modules\translation\jobs\TranslationJob;
class Translation implements Consumer
{
// 要消费的队列名
public $queue = 'transTask';
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
public $connection = 'default';
// 消费
public function consume($data)
{
try {
// 无需反序列化
// var_export($data); // 输出 ['to' => 'tom@gmail.com', 'content' => 'hello']
$job = new TranslationJob($data);
$job->handle();
} catch (\Throwable $e) {
Log::error("TranslationService 异步任务异常: ".$e->getMessage());
}
}
// 消费失败回调
/*
$package = [
'id' => 1357277951, // 消息ID
'time' => 1709170510, // 消息时间
'delay' => 0, // 延迟时间
'attempts' => 2, // 消费次数
'queue' => 'send-mail', // 队列名
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
'max_attempts' => 5, // 最大重试次数
'error' => '错误信息' // 错误信息
]
*/
public function onConsumeFailure(\Throwable $e, $package)
{
echo "consume failure\n";
echo $e->getMessage() . "\n";
// 无需反序列化
var_export($package);
}
}
@@ -0,0 +1,41 @@
<?php
namespace app\mzl\modules\translation\service;
// use Webman\RedisQueue\Client;
use Webman\RedisQueue\Redis;
class TranslationService
{
// 配置
protected $config = [];
public function __construct()
{
$this->config = getConfig('/mzl/modules/translation/config/trans.php');
}
/**
* 异步排队所有语种的翻译任务
*
* @param string $entityType 实体名,如 'vehicle'
* @param int $entityId 主表 ID
* @param string $sourceLocale 源语种,如 'zh'
* @param array $sourceFields 源语种字段 ['name'=>…,'condition_desc'=>…]
* @param array $clientFields 前端已传翻译 ['en'=>[...], …]
*/
public function queueTranslations(string $entityType, int $entityId, string $sourceLocale, array $sourceFields, array $clientFields = []): void
{
foreach ($this->config['locales'] as $locale) {
$payload = [
'entityType' => $entityType,
'entityId' => $entityId,
'locale' => $locale,
'sourceLocale' => $sourceLocale,
'sourceFields' => $sourceFields,
'clientFields' => $clientFields[$locale] ?? [],
];
Redis::send('transTask', $payload);
}
}
}
@@ -0,0 +1,38 @@
<?php
namespace app\mzl\modules\translation\service;
use support\Log;
use app\mzl\common\org\XunfeiFanyi;
class TranslatorClient
{
// protected string $endpoint;
// protected string $apiKey;
// public function __construct()
// {
// $this->endpoint = env('TRANSLATION_API_URL');
// $this->apiKey = env('TRANSLATION_API_KEY');
// }
/**
* 翻译文本
* @throws \RuntimeException
*/
public function translate(string $text, string $from = 'cn', string $to = 'ru'): string
{
try {
$xunfei = new XunfeiFanyi();
$rst_dst = $xunfei->fanyi($text, $from, $to);
if ($rst_dst) {
return $rst_dst["trans_result"]["dst"];
} else {
return '';
}
} catch (\Throwable $e) {
Log::error("TranslatorClient 错误: ".$e->getMessage());
throw new \RuntimeException('翻译服务调用失败');
}
}
}
@@ -0,0 +1,68 @@
<?php
namespace app\mzl\modules\upload\controller;
use support\Request;
use Shopwwi\WebmanFilesystem\Facade\Storage;
use app\mzl\modules\upload\service\OssService;
use app\mzl\common\traits\ResponseTrait;
class UploadController
{
use ResponseTrait;
public function upload(Request $request)
{
try {
$scene = $request->input('scene', 'image');
$file = $request->file('file');
if ($file && $file->isValid()) {
$pathUrl = '/uploads/'.$scene.'/'.date('Ymd').'/'.md5($file->getUploadName().time()).'.'.$file->getUploadExtension();
$fullPathUrl = public_path().$pathUrl;
$file->move($fullPathUrl);
$data = [
'domain' => getenv('MAIN_DOMAIN'),
'file_url' => $pathUrl,
'full_url' => getenv('MAIN_DOMAIN').$pathUrl,
'scene' => $scene,
];
return $this->successResponse($data, 'upload success');
}
return $this->errorResponse('file not found');
} catch (\Throwable $e) {
return $this->errorResponse($e->getMessage());
}
}
// 上传单文件图片
public function uploadImage(Request $request)
{
try {
$file = $request->file('image');
if (!$file || !$file->isValid()) {
return $this->errorResponse('上传失败');
}
$result = Storage::adapter('oss')
->path('vehicle_album/'.date('Ymd'))
->size(1024*1024*5)
->extYes(['image/jpeg','image/png'])
->extNo(['image/gif'])
->upload($file);
$ossservice = new OssService();
$data = [
'image_name' => $result->file_name,
'image_url' => $ossservice->generatePrivateUrl($result->file_name)
];
return $this->successResponse($data);
}catch (\Throwable $e){
var_dump($e->getFile().'-'.$e->getLine().'-'.$e->getMessage());
return $this->errorResponse($e->getMessage());
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace app\mzl\modules\upload\service;
use support\Request;
class LocalService
{
public function __construct()
{
}
/**
* 上传文件
*/
public function upload($file, $path = '', $type = 'excel')
{
// $file = $request->file('file');
if ($file && $file->isValid()) {
$pathUrl = $path == '' ? '/uploads/'.$type.'/'.date('Ymd').'/'.md5($file->getUploadName().time()).'.'.$file->getUploadExtension() : $path;
$fullPathUrl = public_path().$pathUrl;
$file->move($fullPathUrl);
$data = [
'file_url' => $pathUrl,
'full_url' => getenv('MAIN_DOMAIN').$pathUrl
];
return $data;
}
return false;
}
}
@@ -0,0 +1,59 @@
<?php
namespace app\mzl\modules\upload\service;
use OSS\OssClient;
use OSS\Core\OssException;
use support\exception\BusinessException;
class OssService
{
private $accessKeyId;
private $accessKeySecret;
private $bucket;
private $endpoint;
private $ossClient;
public function __construct()
{
$this->accessKeyId = getenv('OSS_ACCESSID');
$this->accessKeySecret = getenv('OSS_ACCESSSECRET');
$this->bucket = getenv('OSS_BUCKET');
$this->endpoint = getenv('OSS_ENDPOINT');
$this->ossClient = new OssClient($this->accessKeyId, $this->accessKeySecret, $this->endpoint);
}
/**
* 上传图片到 OSS
* @param string $filePath 本地文件路径
* @param string $objectKey OSS上存储的对象名称
* @return string 图片的 URL
*/
public function uploadImage($filePath, $objectKey)
{
try {
// 上传文件到 OSS
$this->ossClient->uploadFile($this->bucket, $objectKey, $filePath);
// 构造图片的私有 URL
$url = $this->generatePrivateUrl($objectKey);
return $url;
} catch (OssException $e) {
// 错误处理
throw new \RuntimeException('OSS Error: ' . $e->getMessage());
} catch (\Throwable $th) {
// 错误处理
throw new BusinessException('Error: ' . $th->getMessage());
}
}
/**
* 生成图片的私有访问 URL
* @param string $objectKey
* @return string
*/
public function generatePrivateUrl($objectKey, $expireTime = 3600)
{
// 生成 OSS 私有 URL(有效期为 3600 秒)
return $this->ossClient->signUrl($this->bucket, $objectKey, $expireTime);
}
}
@@ -0,0 +1,174 @@
<?php
namespace app\mzl\modules\wechat\service;
use EasyWeChat\MiniApp\Application as xcxApplication;
use EasyWeChat\OfficialAccount\Application as gzhApplication;
use support\exception\BusinessException;
use support\Log;
class WechatService
{
protected $config = [];
public function __construct(string $type)
{
$this->config = $this->loadConfig($type);
}
/**
* 加载配置
*
* @param string $type 类型
* @return array
* @throws BusinessException
*/
public function loadConfig(string $type): array
{
switch ($type) {
case 'OfficialAccount':
$data = [
'app_id' => getenv('OFFICIALACCOUNT_APP_ID'),
'secret' => getenv('OFFICIALACCOUNT_SECRET'),
'oauth' => [
'scopes' => getenv('OFFICIALACCOUNT_OAUTH_SCOPES')
]
];
break;
case 'MiniApp':
$data = [
'app_id' => getenv('MINIAPP_APP_ID'),
'secret' => getenv('MINIAPP_SECRET'),
'response_type' => 'array',
'log' => [
'level' => 'debug',
'file' => runtime_path().'/mzl/wechat.log',
]
];
break;
case 'Pay':
$data = [
'mch_id' => getenv('MCH_ID'),
'private_key' => base_path() . getenv('PRIVATE_KEY'),
'certificate' => base_path() . getenv('CERTIFICATE'),
'secret_key' => getenv('SECRET_KEY'),
'v2_secret_key' => getenv('V2_SECRET_KEY'),
];
break;
default:
throw new BusinessException('不支持的类型');
break;
}
return $data;
}
// === === === === === === 小程序相关方法 === === === === === ===
/**
* 小程序获取openid
* @param string $code
* @return array
* @throws BusinessException
*/
public function xcxOpenid(string $code): array
{
// 创建微信小程序对象
$wechat = new xcxApplication($this->config);
// 获取 session_key 和 openid
return $wechat->getUtils()->codeToSession($code);
}
/**
* 小程序获取手机号
* @param string $phone_code
* @return mixed
* @throws BusinessException
*/
public function xcxPhoneNumber(string $phone_code)
{
// 创建微信小程序对象
$wechat = new xcxApplication($this->config);
// getPhoneNumber
$data = ['code' => $phone_code];
$result = $wechat->getClient()->postJson('wxa/business/getuserphonenumber', $data);
if (isset($result['phone_info'])) {
// 业务处理 $result['phone_info']['phoneNumber']
return $result;
} else {
throw new BusinessException($result['errmsg']);
}
}
// === === === === === === 公众号相关方法 === === === === === ===
/**
* 公众号获取openid
* @param string $code
* @return mixed
* @throws BusinessException
*/
public function gzhOpenid(string $code)
{
$app = new gzhApplication($this->config);
$app->getClient();
$oauth = $app->getOAuth();
return $oauth->userFromCode($code);
}
/**
* 公众号发送模板消息
* @param string $openid
* @param string $cagetory
* @param string $area
* @param string $time
* @param int $city_id
* @param int $category_id
* @return bool
* @throws BusinessException
*/
public function gzhSendNotice(string $openid, string $cagetory, string $area, string $time, $city_id, $category_id)
{
$wechat = new gzhApplication($this->config);
$token = $wechat->getAccessToken()->getToken();
$template = [
'touser' => $openid,
'template_id' => 'NKThzslzC2XabRjfrq5M-TQO1zILJts5gklQlwvG1W8',
"miniprogram" => [
"appid" => "wxdf4bb512456d3463",
"pagepath" => "pages/index/index?category_id=$category_id&city_id=$city_id&city_name=$area"
],
'data' => [
'thing3' => ['value' => $cagetory],
'thing23' => ['value' => $area],
'time25' => ['value' => $time]
]
];
$url = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=".$token;
$result = $wechat->getClient()->postJson($url, $template);
if ($result['errcode'] == 0) {
return true;
}else{
Log::warning($result->getContent());
throw new BusinessException($result->getContent());
}
}
/**
* 公众号获取用户信息
* @param string $openid
* @return string
* @throws BusinessException
*/
public function gzhUserinfo(string $openid)
{
$wechat = new gzhApplication($this->config);
$token = $wechat->getAccessToken()->getToken();
$url = "https://api.weixin.qq.com/cgi-bin/user/info?access_token=$token&openid=$openid&lang=zh_CN";
$result = $wechat->getClient()->get($url);
return $result->getContent();
}
}