二手车
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\Service\PdfService;
|
||||
use app\Service\UsedCarService;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
|
||||
/**
|
||||
* 二手车控制器
|
||||
*/
|
||||
class UsedCarController extends BaseController
|
||||
{
|
||||
private UsedCarService $service;
|
||||
private PdfService $pdfService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new UsedCarService();
|
||||
$this->pdfService = new PdfService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二手车列表
|
||||
*/
|
||||
public function list(Request $request): Response
|
||||
{
|
||||
$userId = $this->getUserId($request);
|
||||
$type = $request->get('type', 'self');
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 20);
|
||||
$keyword = $request->get('keyword');
|
||||
$status = $request->get('status');
|
||||
|
||||
try {
|
||||
$result = $this->service->getList(
|
||||
$userId,
|
||||
$type,
|
||||
$page,
|
||||
$pageSize,
|
||||
$keyword,
|
||||
$status !== null ? (int)$status : null
|
||||
);
|
||||
|
||||
return $this->success($result);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二手车详情
|
||||
*/
|
||||
public function detail(Request $request): Response
|
||||
{
|
||||
$userId = $this->getUserId($request);
|
||||
$id = (int)$request->get('id');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->error('无效的ID');
|
||||
}
|
||||
|
||||
try {
|
||||
$detail = $this->service->getDetail($id, $userId);
|
||||
return $this->success($detail);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建二手车记录
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$userId = $this->requireAuth($request);
|
||||
$data = $request->post();
|
||||
|
||||
try {
|
||||
$car = $this->service->create($data, $userId);
|
||||
return $this->success($car->toArray(), '创建成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新二手车记录
|
||||
*/
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
$userId = $this->requireAuth($request);
|
||||
$id = (int)$request->get('id');
|
||||
$data = $request->post();
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->error('无效的ID');
|
||||
}
|
||||
|
||||
try {
|
||||
$car = $this->service->update($id, $data, $userId);
|
||||
return $this->success($car->toArray(), '更新成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除二手车记录
|
||||
*/
|
||||
public function delete(Request $request): Response
|
||||
{
|
||||
$userId = $this->requireAuth($request);
|
||||
$id = (int)$request->get('id');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->error('无效的ID');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->delete($id, $userId);
|
||||
return $this->success(null, '删除成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态
|
||||
*/
|
||||
public function updateStatus(Request $request): Response
|
||||
{
|
||||
$userId = $this->requireAuth($request);
|
||||
$id = (int)$request->post('id');
|
||||
$status = (int)$request->post('status');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->error('无效的ID');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->updateStatus($id, $status, $userId);
|
||||
return $this->success(null, '状态更新成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL抓取车辆信息
|
||||
*/
|
||||
public function crawlByUrl(Request $request): Response
|
||||
{
|
||||
$this->requireAuth($request);
|
||||
$url = $request->post('url') ?: $request->get('url');
|
||||
|
||||
if (empty($url)) {
|
||||
return $this->error('URL不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->service->crawlByUrl($url);
|
||||
return $this->success($data);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端链接抓取(处理短链)
|
||||
*/
|
||||
public function crawlMobileUrl(Request $request): Response
|
||||
{
|
||||
$this->requireAuth($request);
|
||||
$url = $request->post('url') ?: $request->get('url');
|
||||
|
||||
if (empty($url)) {
|
||||
return $this->error('URL不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->service->crawlByUrl($url);
|
||||
return $this->success($data);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成PDF
|
||||
*/
|
||||
public function generatePdf(Request $request): Response
|
||||
{
|
||||
$userId = $this->requireAuth($request);
|
||||
$id = (int)$request->post('id');
|
||||
$price = (float)$request->post('price');
|
||||
$lang = $request->post('lang', 'zh');
|
||||
$currency = $request->post('currency', 'CNY');
|
||||
$tradeTerm = $request->post('trade_term', '');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->error('无效的ID');
|
||||
}
|
||||
|
||||
if ($price <= 0) {
|
||||
return $this->error('请输入有效的价格');
|
||||
}
|
||||
|
||||
try {
|
||||
$pdfFileName = $this->pdfService->generateUsedCarPdf(
|
||||
$id,
|
||||
$userId,
|
||||
$price,
|
||||
$lang,
|
||||
$currency,
|
||||
$tradeTerm
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'pdf_file_name' => $pdfFileName,
|
||||
], 'PDF生成成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载PDF
|
||||
*/
|
||||
public function downloadPdf(Request $request): Response
|
||||
{
|
||||
$userId = $this->getUserId($request);
|
||||
$id = (int)$request->get('id');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->error('无效的ID');
|
||||
}
|
||||
|
||||
try {
|
||||
$car = \app\Model\UsedCar::findOrFail($id);
|
||||
|
||||
if (empty($car->pdf_src)) {
|
||||
return $this->error('PDF文件不存在,请先生成PDF');
|
||||
}
|
||||
|
||||
$pdfPath = $this->pdfService->getPdfPath($car->pdf_src);
|
||||
|
||||
if (!$this->pdfService->pdfExists($car->pdf_src)) {
|
||||
return $this->error('PDF文件不存在');
|
||||
}
|
||||
|
||||
// 设置下载的文件名
|
||||
$filename = $car->product_name . '-' . $car->code . '-' . $car->id . '.pdf';
|
||||
|
||||
// 返回文件下载响应
|
||||
return response()->file($pdfPath, $filename);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取PDF预览URL
|
||||
*/
|
||||
public function getPdfUrl(Request $request): Response
|
||||
{
|
||||
$userId = $this->getUserId($request);
|
||||
$id = (int)$request->get('id');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->error('无效的ID');
|
||||
}
|
||||
|
||||
try {
|
||||
$car = \app\Model\UsedCar::findOrFail($id);
|
||||
|
||||
if (empty($car->pdf_src)) {
|
||||
return $this->error('PDF文件不存在,请先生成PDF');
|
||||
}
|
||||
|
||||
if (!$this->pdfService->pdfExists($car->pdf_src)) {
|
||||
return $this->error('PDF文件不存在');
|
||||
}
|
||||
|
||||
// 将PDF复制到public目录以便访问
|
||||
$publicPdfDir = public_path('pdf');
|
||||
if (!is_dir($publicPdfDir)) {
|
||||
mkdir($publicPdfDir, 0755, true);
|
||||
}
|
||||
|
||||
$publicPdfPath = $publicPdfDir . '/' . $car->pdf_src;
|
||||
if (!file_exists($publicPdfPath)) {
|
||||
copy($this->pdfService->getPdfPath($car->pdf_src), $publicPdfPath);
|
||||
}
|
||||
|
||||
// 构建访问URL
|
||||
$baseUrl = $this->getBaseUrl($request);
|
||||
$pdfUrl = $baseUrl . '/pdf/' . $car->pdf_src;
|
||||
|
||||
return $this->success([
|
||||
'pdf_url' => $pdfUrl,
|
||||
'file_name' => $car->product_name . '-' . $car->code . '-' . $car->id . '.pdf',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览PDF(直接返回PDF内容)
|
||||
*/
|
||||
public function previewPdf(Request $request): Response
|
||||
{
|
||||
$id = (int)$request->get('id');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->error('无效的ID');
|
||||
}
|
||||
|
||||
try {
|
||||
$car = \app\Model\UsedCar::findOrFail($id);
|
||||
|
||||
if (empty($car->pdf_src)) {
|
||||
return $this->error('PDF文件不存在,请先生成PDF');
|
||||
}
|
||||
|
||||
$pdfPath = $this->pdfService->getPdfPath($car->pdf_src);
|
||||
|
||||
if (!$this->pdfService->pdfExists($car->pdf_src)) {
|
||||
return $this->error('PDF文件不存在');
|
||||
}
|
||||
|
||||
// 返回PDF内容
|
||||
$content = file_get_contents($pdfPath);
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => '*',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础URL
|
||||
*/
|
||||
private function getBaseUrl(Request $request): string
|
||||
{
|
||||
$scheme = $request->header('x-forwarded-proto') ?? $request->header('x-scheme') ?? 'https';
|
||||
$host = $request->host();
|
||||
return $scheme . '://' . $host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace app\Model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
* 二手车信息模型
|
||||
*/
|
||||
class UsedCar extends Model
|
||||
{
|
||||
protected $table = 'used_car';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'user_id',
|
||||
'role',
|
||||
'product_name',
|
||||
'vin',
|
||||
'price',
|
||||
'source_place',
|
||||
'plate_place',
|
||||
'plate_time',
|
||||
'displacement',
|
||||
'mileage',
|
||||
'power',
|
||||
'body_color',
|
||||
'images',
|
||||
'condition_desc',
|
||||
'attachment',
|
||||
'pdf_src',
|
||||
'source_url',
|
||||
'crawl_title',
|
||||
'crawl_brand',
|
||||
'crawl_brand_id',
|
||||
'crawl_series',
|
||||
'crawl_series_id',
|
||||
'crawl_year',
|
||||
'crawl_msrp',
|
||||
'crawl_car_id',
|
||||
'status',
|
||||
'sort',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'integer',
|
||||
'mileage' => 'integer',
|
||||
'crawl_brand_id' => 'integer',
|
||||
'crawl_series_id' => 'integer',
|
||||
'crawl_msrp' => 'integer',
|
||||
'crawl_car_id' => 'integer',
|
||||
'status' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'attachment' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 状态常量
|
||||
*/
|
||||
const STATUS_NORMAL = 1; // 正常
|
||||
const STATUS_ARCHIVED = 2; // 归档
|
||||
const STATUS_DELETED = 3; // 删除
|
||||
|
||||
/**
|
||||
* 关联用户
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联PDF生成日志
|
||||
*/
|
||||
public function pdfLogs()
|
||||
{
|
||||
return $this->hasMany(UsedCarPdfLog::class, 'used_car_id')->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否可以编辑
|
||||
*/
|
||||
public function canEdit(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_NORMAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否已删除
|
||||
*/
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DELETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否已归档
|
||||
*/
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ARCHIVED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片URL数组
|
||||
*/
|
||||
public function getImageUrlsAttribute(): array
|
||||
{
|
||||
if (empty($this->images)) {
|
||||
return [];
|
||||
}
|
||||
return explode(',', $this->images);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作用域:正常状态
|
||||
*/
|
||||
public function scopeNormal($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_NORMAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作用域:按用户筛选
|
||||
*/
|
||||
public function scopeByUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作用域:搜索关键词
|
||||
*/
|
||||
public function scopeSearch($query, string $keyword)
|
||||
{
|
||||
return $query->where(function ($q) use ($keyword) {
|
||||
$q->where('product_name', 'like', "%{$keyword}%")
|
||||
->orWhere('code', 'like', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace app\Model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
* 二手车PDF生成日志模型
|
||||
*/
|
||||
class UsedCarPdfLog extends Model
|
||||
{
|
||||
protected $table = 'used_car_pdf_log';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'used_car_id',
|
||||
'price_snapshot',
|
||||
'pdf_path',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price_snapshot' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联用户
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联二手车
|
||||
*/
|
||||
public function usedCar()
|
||||
{
|
||||
return $this->belongsTo(UsedCar::class, 'used_car_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 作用域:按二手车筛选
|
||||
*/
|
||||
public function scopeByUsedCar($query, int $usedCarId)
|
||||
{
|
||||
return $query->where('used_car_id', $usedCarId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作用域:按用户筛选
|
||||
*/
|
||||
public function scopeByUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\Model\UsedCar;
|
||||
use support\Db;
|
||||
use support\Log;
|
||||
use Workerman\Crontab\Crontab;
|
||||
|
||||
/**
|
||||
* 定时任务进程
|
||||
* 用于定期清理PDF文件和归档二手车记录
|
||||
*/
|
||||
class Task
|
||||
{
|
||||
public function onWorkerStart()
|
||||
{
|
||||
// 每天凌晨3点清理PDF文件
|
||||
new Crontab('0 0 3 * * *', function () {
|
||||
Log::info('开始清理PDF文件', ['time' => date('Y-m-d H:i:s')]);
|
||||
|
||||
// 清空数据库中的PDF路径
|
||||
Db::table('used_car')->update(['pdf_src' => '']);
|
||||
|
||||
// 删除runtime目录下的PDF文件
|
||||
$pdfFiles = glob(runtime_path('pdf/*.pdf'));
|
||||
if (is_array($pdfFiles)) {
|
||||
array_map('unlink', $pdfFiles);
|
||||
}
|
||||
|
||||
// 删除public目录下的PDF文件
|
||||
$publicPdfFiles = glob(public_path('pdf/*.pdf'));
|
||||
if (is_array($publicPdfFiles)) {
|
||||
array_map('unlink', $publicPdfFiles);
|
||||
}
|
||||
|
||||
Log::info('PDF文件清理完成', ['time' => date('Y-m-d H:i:s')]);
|
||||
});
|
||||
|
||||
// 每3小时自动归档15天前的二手车记录(将状态改为删除)
|
||||
new Crontab('0 25 */3 * * *', function () {
|
||||
Log::info('开始归档二手车记录', ['time' => date('Y-m-d H:i:s')]);
|
||||
|
||||
// 将15天前创建的记录状态改为删除(状态=3)
|
||||
$count = Db::table('used_car')
|
||||
->where('status', UsedCar::STATUS_NORMAL)
|
||||
->where('created_at', '<', date('Y-m-d H:i:s', time() - (86400 * 15)))
|
||||
->update(['status' => UsedCar::STATUS_DELETED]);
|
||||
|
||||
Log::info('归档完成', ['affected_rows' => $count, 'time' => date('Y-m-d H:i:s')]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
<?php
|
||||
|
||||
namespace app\Service;
|
||||
|
||||
use app\Model\UsedCar;
|
||||
use app\Model\UsedCarPdfLog;
|
||||
use app\Model\User;
|
||||
use Mpdf\Mpdf;
|
||||
|
||||
/**
|
||||
* PDF生成服务
|
||||
* 用于生成二手车相关的PDF文档
|
||||
*/
|
||||
class PdfService
|
||||
{
|
||||
/**
|
||||
* 语言映射
|
||||
*/
|
||||
private const LANGUAGES = [
|
||||
'zh' => '中文',
|
||||
'en' => '英语',
|
||||
'ru' => '俄语',
|
||||
'es' => '西班牙语',
|
||||
'fr' => '法语',
|
||||
];
|
||||
|
||||
/**
|
||||
* 货币符号映射
|
||||
*/
|
||||
private const CURRENCIES = [
|
||||
'CNY' => '¥',
|
||||
'USD' => '$',
|
||||
'RUB' => '₽',
|
||||
'AED' => 'د.إ',
|
||||
'EUR' => '€',
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成二手车PDF
|
||||
*
|
||||
* @param int $usedCarId 二手车ID
|
||||
* @param int $userId 用户ID
|
||||
* @param float $price 价格(用于PDF显示)
|
||||
* @param string $lang 语言代码
|
||||
* @param string $currency 货币代码
|
||||
* @param string $tradeTerm 贸易条款
|
||||
* @return string PDF文件名
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function generateUsedCarPdf(int $usedCarId, int $userId, float $price, string $lang = 'zh', string $currency = 'CNY', string $tradeTerm = ''): string
|
||||
{
|
||||
// 验证参数
|
||||
if (!isset(self::LANGUAGES[$lang])) {
|
||||
throw new \Exception('不支持的语言: ' . $lang);
|
||||
}
|
||||
if (!isset(self::CURRENCIES[$currency])) {
|
||||
throw new \Exception('不支持的货币: ' . $currency);
|
||||
}
|
||||
|
||||
// 获取车辆信息
|
||||
$car = UsedCar::with('user')->findOrFail($usedCarId);
|
||||
|
||||
// 检查权限
|
||||
if ($car->user_id !== $userId) {
|
||||
throw new \Exception('无权生成此车辆的PDF');
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
$user = $car->user ?? User::findOrFail($userId);
|
||||
|
||||
// 准备PDF数据
|
||||
$pdfData = $this->preparePdfData($car, $price, $lang, $currency, $tradeTerm, $user);
|
||||
|
||||
// 生成PDF
|
||||
$pdfFileName = $this->renderPdf($pdfData, $car, $user, $lang);
|
||||
|
||||
// 更新车辆记录
|
||||
$car->pdf_src = $pdfFileName;
|
||||
$car->save();
|
||||
|
||||
// 记录PDF生成日志
|
||||
UsedCarPdfLog::create([
|
||||
'user_id' => $userId,
|
||||
'used_car_id' => $usedCarId,
|
||||
'price_snapshot' => (int)($price * 100), // 转换为分
|
||||
'pdf_path' => $pdfFileName,
|
||||
]);
|
||||
|
||||
// 清理临时文件
|
||||
$this->cleanupTempFiles();
|
||||
|
||||
return $pdfFileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备PDF数据
|
||||
*/
|
||||
private function preparePdfData(UsedCar $car, float $price, string $lang, string $currency, string $tradeTerm, User $user): array
|
||||
{
|
||||
// 获取字段翻译
|
||||
$field = $this->getFieldTranslations($lang);
|
||||
$field['currency'] = self::CURRENCIES[$currency];
|
||||
|
||||
// 获取名片翻译
|
||||
$contactField = $this->getContactTranslations($lang);
|
||||
|
||||
// 构建车辆名称
|
||||
$productName = trim(($car->crawl_brand ?? '') . ' ' . ($car->crawl_series ?? '') . ' ' . ($car->crawl_year ?? '') . '款 ' . ($car->crawl_title ?? $car->product_name));
|
||||
|
||||
// 处理图片 - 下载到本地
|
||||
$images = [];
|
||||
$imageDir = runtime_path('pdf/images/');
|
||||
if (!is_dir($imageDir)) {
|
||||
mkdir($imageDir, 0755, true);
|
||||
}
|
||||
|
||||
if (!empty($car->images)) {
|
||||
$imageUrls = explode(',', $car->images);
|
||||
$crawlService = new UsedCarCrawlService();
|
||||
$downloadedImages = $crawlService->downloadImages($imageUrls, $imageDir);
|
||||
|
||||
foreach ($downloadedImages as $localPath) {
|
||||
if ($localPath && file_exists($localPath)) {
|
||||
$images[] = $localPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理附件PDF
|
||||
$attachments = [];
|
||||
if (!empty($car->attachment)) {
|
||||
$attachmentData = json_decode($car->attachment, true);
|
||||
if (is_array($attachmentData)) {
|
||||
$attachmentDir = runtime_path('pdf/attachment/');
|
||||
if (!is_dir($attachmentDir)) {
|
||||
mkdir($attachmentDir, 0755, true);
|
||||
}
|
||||
|
||||
foreach ($attachmentData as $attachment) {
|
||||
$src = $attachment['src'] ?? '';
|
||||
if (str_ends_with(strtolower($src), '.pdf')) {
|
||||
// 下载PDF附件
|
||||
$localPath = $this->downloadAttachment($src, $attachmentDir);
|
||||
if ($localPath && file_exists($localPath)) {
|
||||
$attachments[] = $localPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理社交二维码
|
||||
$socialCode = '';
|
||||
if (!empty($user->social_code)) {
|
||||
$socialCodePath = $this->downloadSocialCode($user->social_code);
|
||||
if ($socialCodePath && file_exists($socialCodePath)) {
|
||||
$socialCode = $socialCodePath;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'car' => [
|
||||
'product_name' => $productName,
|
||||
'price' => $price,
|
||||
'trade' => $tradeTerm,
|
||||
'plate_time' => $car->plate_time ?? '',
|
||||
'displacement' => $car->displacement ?? '',
|
||||
'mileage' => $car->mileage ?? 0,
|
||||
'power' => $car->power ?? '',
|
||||
'body_color' => $car->body_color ?? '',
|
||||
'condition_desc' => $car->condition_desc ?? '',
|
||||
'images' => $images,
|
||||
'attachment' => $attachments,
|
||||
'created_at' => $car->created_at ? $car->created_at->format('Y-m-d H:i:s') : '',
|
||||
],
|
||||
'user' => [
|
||||
'name' => $user->name ?? '',
|
||||
'name_en' => $user->name_en ?? $user->name ?? '',
|
||||
'email' => $user->email ?? '',
|
||||
'phone' => $user->phone ?? '',
|
||||
'whatsapp' => $user->whatsapp ?? '',
|
||||
'social' => !empty($user->social_code),
|
||||
'social_code' => $socialCode,
|
||||
],
|
||||
'field' => $field,
|
||||
'contact_field' => $contactField,
|
||||
'lang' => $lang,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染PDF
|
||||
*/
|
||||
private function renderPdf(array $pdfData, UsedCar $car, User $user, string $lang): string
|
||||
{
|
||||
// 创建临时目录
|
||||
$tempDir = runtime_path('pdf/temp/');
|
||||
if (!is_dir($tempDir)) {
|
||||
mkdir($tempDir, 0755, true);
|
||||
}
|
||||
|
||||
// 初始化mPDF
|
||||
$mpdf = new Mpdf([
|
||||
'mode' => 'utf-8',
|
||||
'format' => 'A4-L', // 横向
|
||||
'margin_top' => 10,
|
||||
'margin_bottom' => 10,
|
||||
'margin_left' => 10,
|
||||
'margin_right' => 10,
|
||||
'tempDir' => $tempDir,
|
||||
'default_font' => 'sun-exta',
|
||||
]);
|
||||
|
||||
// 设置水印
|
||||
$watermarkText = $pdfData['user']['name_en'] ?? $pdfData['user']['name'];
|
||||
if (!empty($watermarkText)) {
|
||||
$mpdf->SetWatermarkText($watermarkText, 0.08);
|
||||
$mpdf->showWatermarkText = true;
|
||||
}
|
||||
|
||||
// 渲染车辆详情页面
|
||||
ob_start();
|
||||
$carTpl = runtime_path('pdf/templates/usedCar.stub');
|
||||
extract([
|
||||
'car' => $pdfData['car'],
|
||||
'field' => $pdfData['field'],
|
||||
]);
|
||||
include $carTpl;
|
||||
$carHtml = ob_get_clean();
|
||||
$mpdf->WriteHTML($carHtml);
|
||||
|
||||
// 追加附件PDF
|
||||
foreach ($pdfData['car']['attachment'] as $attachmentPdf) {
|
||||
$this->appendPdf($mpdf, $attachmentPdf);
|
||||
}
|
||||
|
||||
// 添加名片页面
|
||||
$mpdf->AddPage();
|
||||
ob_start();
|
||||
$cardTpl = runtime_path('pdf/templates/contactCard.stub');
|
||||
extract([
|
||||
'user' => $pdfData['user'],
|
||||
'contactField' => $pdfData['contact_field'],
|
||||
]);
|
||||
include $cardTpl;
|
||||
$cardHtml = ob_get_clean();
|
||||
$mpdf->WriteHTML($cardHtml);
|
||||
|
||||
// 生成PDF文件
|
||||
$pdfDir = runtime_path('pdf/');
|
||||
if (!is_dir($pdfDir)) {
|
||||
mkdir($pdfDir, 0755, true);
|
||||
}
|
||||
|
||||
$pdfFileName = 'usedcar-' . $car->code . '-' . $car->id . '-' . $lang . '-' . substr(time(), -6) . '.pdf';
|
||||
$pdfPath = $pdfDir . '/' . $pdfFileName;
|
||||
|
||||
$mpdf->Output($pdfPath, \Mpdf\Output\Destination::FILE);
|
||||
|
||||
return $pdfFileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加PDF到现有文档
|
||||
*/
|
||||
private function appendPdf(Mpdf $mpdf, string $pdfPath): void
|
||||
{
|
||||
if (!file_exists($pdfPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pageCount = $mpdf->SetSourceFile($pdfPath);
|
||||
for ($i = 1; $i <= $pageCount; $i++) {
|
||||
$tplId = $mpdf->ImportPage($i);
|
||||
$size = $mpdf->getTemplateSize($tplId);
|
||||
|
||||
$mpdf->AddPageByArray([
|
||||
'orientation' => $size['orientation'],
|
||||
'sheet-size' => [$size['width'], $size['height']]
|
||||
]);
|
||||
|
||||
$mpdf->UseTemplate($tplId);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('追加PDF失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载附件
|
||||
*/
|
||||
private function downloadAttachment(string $url, string $saveDir): ?string
|
||||
{
|
||||
$filename = md5($url) . '.pdf';
|
||||
$savePath = $saveDir . $filename;
|
||||
|
||||
if (file_exists($savePath)) {
|
||||
return $savePath;
|
||||
}
|
||||
|
||||
try {
|
||||
$content = file_get_contents($url);
|
||||
if ($content !== false) {
|
||||
file_put_contents($savePath, $content);
|
||||
return $savePath;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('下载附件失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载社交二维码
|
||||
*/
|
||||
private function downloadSocialCode(string $url): ?string
|
||||
{
|
||||
$saveDir = runtime_path('pdf/qrcode/');
|
||||
if (!is_dir($saveDir)) {
|
||||
mkdir($saveDir, 0755, true);
|
||||
}
|
||||
|
||||
$filename = md5($url) . '.jpg';
|
||||
$savePath = $saveDir . $filename;
|
||||
|
||||
if (file_exists($savePath)) {
|
||||
return $savePath;
|
||||
}
|
||||
|
||||
try {
|
||||
$content = file_get_contents($url);
|
||||
if ($content !== false) {
|
||||
file_put_contents($savePath, $content);
|
||||
return $savePath;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('下载二维码失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时文件
|
||||
*/
|
||||
private function cleanupTempFiles(): void
|
||||
{
|
||||
// 清理图片
|
||||
$imageDir = runtime_path('pdf/images/');
|
||||
if (is_dir($imageDir)) {
|
||||
array_map('unlink', glob($imageDir . '*.jpg') ?: []);
|
||||
array_map('unlink', glob($imageDir . '*.png') ?: []);
|
||||
}
|
||||
|
||||
// 清理附件
|
||||
$attachmentDir = runtime_path('pdf/attachment/');
|
||||
if (is_dir($attachmentDir)) {
|
||||
array_map('unlink', glob($attachmentDir . '*.pdf') ?: []);
|
||||
}
|
||||
|
||||
// 清理二维码
|
||||
$qrcodeDir = runtime_path('pdf/qrcode/');
|
||||
if (is_dir($qrcodeDir)) {
|
||||
array_map('unlink', glob($qrcodeDir . '*.jpg') ?: []);
|
||||
array_map('unlink', glob($qrcodeDir . '*.png') ?: []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段翻译
|
||||
*/
|
||||
private function getFieldTranslations(string $lang): array
|
||||
{
|
||||
$translations = [
|
||||
'zh' => [
|
||||
'price' => '车价',
|
||||
'source_place' => '车源地',
|
||||
'plate_time' => '上牌时间',
|
||||
'displacement' => '排量',
|
||||
'mileage' => '里程',
|
||||
'power' => '马力',
|
||||
'body_color' => '车身颜色',
|
||||
'created_at' => '创建时间',
|
||||
'condition_desc' => '车况介绍',
|
||||
'images' => '产品图',
|
||||
'trade' => '贸易条款',
|
||||
],
|
||||
'en' => [
|
||||
'price' => 'Price',
|
||||
'source_place' => 'Vehicle Location',
|
||||
'plate_time' => 'Registration Date',
|
||||
'displacement' => 'Engine Displacement',
|
||||
'mileage' => 'Mileage',
|
||||
'power' => 'Horsepower',
|
||||
'body_color' => 'Body Color',
|
||||
'created_at' => 'Created At',
|
||||
'condition_desc' => 'Vehicle Condition',
|
||||
'images' => 'Product Image',
|
||||
'trade' => 'Trade Terms',
|
||||
],
|
||||
'ru' => [
|
||||
'price' => 'Цена',
|
||||
'source_place' => 'Местонахождение автомобиля',
|
||||
'plate_time' => 'Дата регистрации',
|
||||
'displacement' => 'Объём двигателя',
|
||||
'mileage' => 'Пробег',
|
||||
'power' => 'Мощность',
|
||||
'body_color' => 'Цвет кузова',
|
||||
'created_at' => 'Дата создания',
|
||||
'condition_desc' => 'Состояние автомобиля',
|
||||
'images' => 'Фотографии',
|
||||
'trade' => 'Условия торговли',
|
||||
],
|
||||
'es' => [
|
||||
'price' => 'Precio',
|
||||
'source_place' => 'Ubicación del vehículo',
|
||||
'plate_time' => 'Fecha de matriculación',
|
||||
'displacement' => 'Cilindrada',
|
||||
'mileage' => 'Kilometraje',
|
||||
'power' => 'Potencia',
|
||||
'body_color' => 'Color de la carrocería',
|
||||
'created_at' => 'Fecha de creación',
|
||||
'condition_desc' => 'Estado del vehículo',
|
||||
'images' => 'Imagen del producto',
|
||||
'trade' => 'Condiciones de comercio',
|
||||
],
|
||||
'fr' => [
|
||||
'price' => 'Prix',
|
||||
'source_place' => 'Emplacement du véhicule',
|
||||
'plate_time' => 'Date d\'immatriculation',
|
||||
'displacement' => 'Cylindrée',
|
||||
'mileage' => 'Kilométrage',
|
||||
'power' => 'Puissance',
|
||||
'body_color' => 'Couleur de la carrosserie',
|
||||
'created_at' => 'Date de création',
|
||||
'condition_desc' => 'État du véhicule',
|
||||
'images' => 'Image du produit',
|
||||
'trade' => 'Conditions commerciales',
|
||||
],
|
||||
];
|
||||
|
||||
return $translations[$lang] ?? $translations['zh'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取名片翻译
|
||||
*/
|
||||
private function getContactTranslations(string $lang): array
|
||||
{
|
||||
$translations = [
|
||||
'zh' => [
|
||||
'title' => '联系人名片',
|
||||
'name' => '姓名',
|
||||
'email' => '邮箱',
|
||||
'phone' => '电话',
|
||||
'code' => '二维码',
|
||||
'left' => '扫码即可联系本人',
|
||||
'right' => '本资料随PDF自动生成',
|
||||
],
|
||||
'en' => [
|
||||
'title' => 'Contact Name Card',
|
||||
'name' => 'Name',
|
||||
'email' => 'Email',
|
||||
'phone' => 'Phone',
|
||||
'code' => 'QR Code',
|
||||
'left' => 'Scan the code to contact me',
|
||||
'right' => 'This information is automatically generated with the PDF',
|
||||
],
|
||||
'ru' => [
|
||||
'title' => 'Контактная карточка',
|
||||
'name' => 'Имя',
|
||||
'email' => 'Электронная почта',
|
||||
'phone' => 'Телефон',
|
||||
'code' => 'QR-код',
|
||||
'left' => 'Код позволяет связаться с самим собой.',
|
||||
'right' => 'Эта информация автоматически генерируется с помощью PDF',
|
||||
],
|
||||
'es' => [
|
||||
'title' => 'Tarjeta de visita de contacto',
|
||||
'name' => 'Nombre',
|
||||
'email' => 'Correo electrónico',
|
||||
'phone' => 'Teléfono',
|
||||
'code' => 'Código QR',
|
||||
'left' => 'Escanee el código para contactarme.',
|
||||
'right' => 'Esta información se genera automáticamente con PDF',
|
||||
],
|
||||
'fr' => [
|
||||
'title' => 'Carte de visite de contact',
|
||||
'name' => 'Nom',
|
||||
'email' => 'E-mail',
|
||||
'phone' => 'Téléphone',
|
||||
'code' => 'Code QR',
|
||||
'left' => 'Scannez le code pour me contacter',
|
||||
'right' => 'Ces informations sont générées automatiquement avec le PDF',
|
||||
],
|
||||
];
|
||||
|
||||
return $translations[$lang] ?? $translations['zh'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取PDF文件路径
|
||||
*/
|
||||
public function getPdfPath(string $pdfFileName): string
|
||||
{
|
||||
return runtime_path('pdf/' . $pdfFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查PDF文件是否存在
|
||||
*/
|
||||
public function pdfExists(string $pdfFileName): bool
|
||||
{
|
||||
return file_exists($this->getPdfPath($pdfFileName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
<?php
|
||||
|
||||
namespace app\Service;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use QL\QueryList;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 二手车数据抓取服务
|
||||
* 支持从懂车帝、汽车之家等网站抓取车辆信息
|
||||
*/
|
||||
class UsedCarCrawlService
|
||||
{
|
||||
private Client $httpClient;
|
||||
|
||||
// 默认请求头
|
||||
private const DEFAULT_HEADERS = [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept-Language' => 'zh-CN,zh;q=0.9',
|
||||
'Referer' => 'https://www.dongchedi.com/',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->httpClient = new Client([
|
||||
'verify' => false,
|
||||
'timeout' => 15,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择抓取方式并执行
|
||||
*
|
||||
* @param string $url 车辆详情页URL
|
||||
* @return array 抓取结果
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function crawlByUrl(string $url): array
|
||||
{
|
||||
// 根据URL判断来源并选择抓取方法
|
||||
if (str_contains($url, 'dongchedi.com')) {
|
||||
// 懂车帝PC端
|
||||
return $this->crawlDongchedi($url);
|
||||
} elseif (str_contains($url, 'dcdapp.com')) {
|
||||
// 懂车帝移动端 - 需要先转换为PC端
|
||||
$pcUrl = $this->getDongchediPcUrl($url);
|
||||
if (!$pcUrl) {
|
||||
throw new \Exception('无法获取懂车帝PC端链接');
|
||||
}
|
||||
return $this->crawlDongchedi($pcUrl);
|
||||
} elseif (str_contains($url, 't.zijieimg.com')) {
|
||||
// 懂车帝分享短链
|
||||
$finalUrl = $this->getFinalUrl($url);
|
||||
$pcUrl = $this->getDongchediPcUrl($finalUrl);
|
||||
if (!$pcUrl) {
|
||||
throw new \Exception('无法获取懂车帝PC端链接');
|
||||
}
|
||||
return $this->crawlDongchedi($pcUrl);
|
||||
} elseif (str_contains($url, 'che168.com')) {
|
||||
// 汽车之家
|
||||
return $this->crawlChe168($url);
|
||||
}
|
||||
|
||||
throw new \Exception('暂不支持此网站的数据抓取');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取短链的最终URL
|
||||
*/
|
||||
private function getFinalUrl(string $url): string
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->get($url, [
|
||||
'allow_redirects' => ['track_redirects' => true],
|
||||
]);
|
||||
|
||||
$history = $response->getHeader('X-Guzzle-Redirect-History');
|
||||
return end($history) ?: $url;
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('获取短链失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从懂车帝移动端URL获取PC端URL
|
||||
*/
|
||||
private function getDongchediPcUrl(string $url): ?string
|
||||
{
|
||||
$query = parse_url($url, PHP_URL_QUERY);
|
||||
if (!$query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parse_str($query, $params);
|
||||
$skuId = $params['sku_id'] ?? null;
|
||||
|
||||
return $skuId ? 'https://www.dongchedi.com/usedcar/' . $skuId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取懂车帝二手车信息
|
||||
*/
|
||||
private function crawlDongchedi(string $url): array
|
||||
{
|
||||
try {
|
||||
$html = $this->fetchHtml($url);
|
||||
|
||||
if (str_contains($html, '404 Not Found')) {
|
||||
throw new \Exception('车辆已下架或找不到页面');
|
||||
}
|
||||
|
||||
return $this->parseDongchediHtml($html, $url);
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('抓取懂车帝数据失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取汽车之家二手车信息
|
||||
*/
|
||||
private function crawlChe168(string $url): array
|
||||
{
|
||||
try {
|
||||
// 处理不同格式的汽车之家URL
|
||||
if (str_contains($url, 'home?infoid=')) {
|
||||
// PC端链接转换
|
||||
$queryString = parse_url($url, PHP_URL_QUERY);
|
||||
parse_str($queryString, $params);
|
||||
$carId = $params['infoid'] ?? null;
|
||||
$url = 'https://www.che168.com/dealer/111/' . $carId . '.html?pvareaid=0';
|
||||
} elseif (str_contains($url, 'wap.che168.com/dealer')) {
|
||||
// 移动端链接转换
|
||||
$url = $this->getChe168PcUrl($url);
|
||||
}
|
||||
|
||||
$html = $this->fetchHtml($url);
|
||||
return $this->parseChe168Html($html, $url);
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('抓取汽车之家数据失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从汽车之家移动端URL获取PC端URL
|
||||
*/
|
||||
private function getChe168PcUrl(string $url): string
|
||||
{
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
$parts = explode('/', $path);
|
||||
$filename = end($parts);
|
||||
$carId = pathinfo($filename, PATHINFO_FILENAME);
|
||||
|
||||
return 'https://www.che168.com/dealer/111/' . $carId . '.html?pvareaid=0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网页HTML内容
|
||||
*/
|
||||
private function fetchHtml(string $url): string
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->get($url, [
|
||||
'headers' => self::DEFAULT_HEADERS,
|
||||
]);
|
||||
return (string)$response->getBody();
|
||||
} catch (GuzzleException $e) {
|
||||
throw new \Exception('获取页面失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析懂车帝HTML
|
||||
*/
|
||||
private function parseDongchediHtml(string $html, string $url): array
|
||||
{
|
||||
try {
|
||||
$ql = QueryList::html($html);
|
||||
|
||||
// 提取JSON数据
|
||||
$jsonText = $ql->find('#__NEXT_DATA__')->text();
|
||||
if (empty($jsonText)) {
|
||||
throw new \Exception('无法找到页面数据');
|
||||
}
|
||||
|
||||
$jsonData = json_decode($jsonText, true);
|
||||
if (!$jsonData) {
|
||||
throw new \Exception('解析JSON数据失败');
|
||||
}
|
||||
|
||||
$skuDetail = $jsonData['props']['pageProps']['skuDetail'] ?? [];
|
||||
$carInfo = $skuDetail['car_info'] ?? [];
|
||||
|
||||
// 提取价格(懂车帝使用特殊字符编码)
|
||||
$priceText = $ql->find('.head-info_price-wrap__Y4bxi > p')->text();
|
||||
$price = $this->decodeDongchediPrice($priceText);
|
||||
|
||||
// 提取车辆信息
|
||||
$title = $carInfo['car_name'] ?? '';
|
||||
$brandName = $carInfo['brand_name'] ?? '';
|
||||
$seriesName = $carInfo['series_name'] ?? '';
|
||||
$year = $carInfo['year'] ?? '';
|
||||
$bodyColor = $carInfo['body_color'] ?? '';
|
||||
|
||||
// 提取配置信息
|
||||
$overview = $skuDetail['car_config_overview'] ?? [];
|
||||
$power = $overview['power']['horsepower'] ?? '';
|
||||
$displacement = $overview['power']['capacity'] ?? '';
|
||||
|
||||
// 提取上牌时间和车源地
|
||||
$otherParams = $skuDetail['other_params'] ?? [];
|
||||
$plateTime = $this->getParamValue($otherParams, '上牌时间');
|
||||
$sourcePlace = $this->getParamValue($otherParams, '车源地');
|
||||
|
||||
// 提取里程
|
||||
$importantText = $skuDetail['important_text'] ?? '';
|
||||
$mileage = 0;
|
||||
if (preg_match('/(\d+\.?\d*)\s*万公里/', $importantText, $matches)) {
|
||||
$mileage = (int)($matches[1] * 10000);
|
||||
}
|
||||
|
||||
// 提取图片
|
||||
$images = $skuDetail['head_images'] ?? [];
|
||||
|
||||
// 提取车况介绍
|
||||
$conditionDesc = '';
|
||||
if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/s', $html, $matches)) {
|
||||
$conditionDesc = trim($matches[1]);
|
||||
}
|
||||
|
||||
// 提取车辆ID
|
||||
$carId = $carInfo['car_id'] ?? 0;
|
||||
$brandId = $carInfo['brand_id'] ?? 0;
|
||||
$seriesId = $carInfo['series_id'] ?? 0;
|
||||
$usedCarId = $this->extractIdFromUrl($url);
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'price' => $price,
|
||||
'brand_name' => $brandName,
|
||||
'brand_id' => $brandId,
|
||||
'series_name' => $seriesName,
|
||||
'series_id' => $seriesId,
|
||||
'year' => $year,
|
||||
'car_id' => $carId,
|
||||
'used_car_id' => $usedCarId,
|
||||
'power' => $power,
|
||||
'displacement' => $displacement,
|
||||
'plate_time' => $plateTime,
|
||||
'source_place' => $sourcePlace,
|
||||
'body_color' => $bodyColor,
|
||||
'mileage' => $mileage,
|
||||
'images' => $images,
|
||||
'condition_desc' => $conditionDesc,
|
||||
'source_url' => $url,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('解析懂车帝页面失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析汽车之家HTML
|
||||
*/
|
||||
private function parseChe168Html(string $html, string $url): array
|
||||
{
|
||||
try {
|
||||
// 汽车之家使用 GB2312 编码,需要转换
|
||||
$html = $this->convertToUtf8($html);
|
||||
|
||||
$ql = QueryList::html($html);
|
||||
|
||||
// 提取车辆名称
|
||||
$productName = $ql->find('div.car-box .car-brand-name')->text();
|
||||
|
||||
// 提取价格
|
||||
$price = $ql->find('div.goodstartmoney')->text();
|
||||
if (empty($price)) {
|
||||
$price = $ql->find('span#overlayPrice')->text();
|
||||
}
|
||||
$price = str_replace(['¥', '¥', '万'], '', $price);
|
||||
$price = is_numeric($price) ? (int)($price * 10000) : 0;
|
||||
|
||||
// 提取基本信息
|
||||
$basicInfo = [];
|
||||
$ql->find('div#nav1 ul.basic-item-ul li')->each(function($li) use (&$basicInfo) {
|
||||
$field = trim($li->find('.item-name')->text());
|
||||
$value = trim(str_replace($field, '', $li->text()));
|
||||
|
||||
$fieldMap = [
|
||||
'上牌时间' => 'plate_time',
|
||||
'表显里程' => 'mileage_text',
|
||||
'排 量' => 'displacement',
|
||||
'所 在 地' => 'source_place',
|
||||
'车身颜色' => 'body_color',
|
||||
'车况介绍' => 'condition_desc',
|
||||
];
|
||||
|
||||
if (isset($fieldMap[$field])) {
|
||||
$basicInfo[$fieldMap[$field]] = $value;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理里程
|
||||
$mileage = 0;
|
||||
if (isset($basicInfo['mileage_text'])) {
|
||||
$mileage = (int)str_replace('万公里', '', $basicInfo['mileage_text']) * 10000;
|
||||
}
|
||||
|
||||
// 提取面包屑导航获取品牌和车系
|
||||
$breadcrumbs = [];
|
||||
$ql->find('.bread-crumbs a')->each(function($a) use (&$breadcrumbs) {
|
||||
$breadcrumbs[] = trim($a->text());
|
||||
});
|
||||
|
||||
$brandName = $breadcrumbs[3] ?? '';
|
||||
$seriesName = $breadcrumbs[4] ?? '';
|
||||
$brandName = str_replace('二手', '', $brandName);
|
||||
$seriesName = str_replace('二手', '', $seriesName);
|
||||
|
||||
// 提取年份
|
||||
$year = '';
|
||||
if (preg_match('/(\d{4})款/', $productName, $matches)) {
|
||||
$year = $matches[1];
|
||||
}
|
||||
|
||||
// 提取图片
|
||||
$images = [];
|
||||
$ql->find('.swiper-container2 .swiper-slide .jiaodianphotoclick img')->each(function($img) use (&$images) {
|
||||
$src = $img->attr('src');
|
||||
if ($src && !str_contains($src, 'default-che168')) {
|
||||
$images[] = $src;
|
||||
}
|
||||
});
|
||||
|
||||
// 提取车辆ID
|
||||
$usedCarId = $this->extractIdFromUrl($url);
|
||||
|
||||
return [
|
||||
'title' => $productName,
|
||||
'product_name' => $productName,
|
||||
'price' => $price,
|
||||
'brand_name' => $brandName,
|
||||
'brand_id' => 0,
|
||||
'series_name' => $seriesName,
|
||||
'series_id' => 0,
|
||||
'year' => $year,
|
||||
'car_id' => 0,
|
||||
'used_car_id' => $usedCarId,
|
||||
'plate_time' => $basicInfo['plate_time'] ?? '',
|
||||
'displacement' => $basicInfo['displacement'] ?? '',
|
||||
'source_place' => $basicInfo['source_place'] ?? '',
|
||||
'body_color' => $basicInfo['body_color'] ?? '',
|
||||
'mileage' => $mileage,
|
||||
'images' => $images,
|
||||
'condition_desc' => $basicInfo['condition_desc'] ?? '',
|
||||
'source_url' => $url,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('解析汽车之家页面失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码懂车帝价格(特殊字符编码)
|
||||
*/
|
||||
private function decodeDongchediPrice(string $str): float
|
||||
{
|
||||
$map = [
|
||||
"\u{E53D}" => '1',
|
||||
"\u{E3F0}" => '2',
|
||||
"\u{E422}" => '3',
|
||||
"\u{E42C}" => '4',
|
||||
"\u{E49C}" => '5',
|
||||
"\u{E42B}" => '6',
|
||||
"\u{E4FE}" => '7',
|
||||
"\u{E548}" => '8',
|
||||
"\u{E4C8}" => '9',
|
||||
"\u{E453}" => '0',
|
||||
"\u{E45F}" => '.',
|
||||
];
|
||||
|
||||
$priceStr = strtr($str, $map);
|
||||
$priceStr = str_replace('万', '', $priceStr);
|
||||
|
||||
return (float)trim($priceStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL中提取ID
|
||||
*/
|
||||
private function extractIdFromUrl(string $url): int
|
||||
{
|
||||
if (preg_match('/(\d+)\.html?$/', $url, $matches)) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
if (preg_match('/\/usedcar\/(\d+)/', $url, $matches)) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从参数列表中获取指定名称的值
|
||||
*/
|
||||
private function getParamValue(array $params, string $name): ?string
|
||||
{
|
||||
foreach ($params as $item) {
|
||||
if (($item['name'] ?? null) === $name) {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将GB2312编码转换为UTF-8
|
||||
*/
|
||||
private function convertToUtf8(string $html): string
|
||||
{
|
||||
if (!function_exists('mb_convert_encoding')) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$isGb = stripos($html, 'charset=gb2312') !== false || stripos($html, 'charset=gbk') !== false;
|
||||
if (!$isGb) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$out = @mb_convert_encoding($html, 'UTF-8', 'GB2312');
|
||||
if ($out === false || $out === '') {
|
||||
$out = @mb_convert_encoding($html, 'UTF-8', 'GBK');
|
||||
}
|
||||
|
||||
if ($out !== false && $out !== '') {
|
||||
$html = preg_replace('/charset\s*=\s*["\']?gb(2312|k)["\']?/i', 'charset="utf-8"', $html);
|
||||
return $out;
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图片到本地
|
||||
*
|
||||
* @param string $url 图片URL
|
||||
* @param string $saveDir 保存目录
|
||||
* @return string|null 保存的文件路径,失败返回null
|
||||
*/
|
||||
public function downloadImage(string $url, string $saveDir): ?string
|
||||
{
|
||||
try {
|
||||
if (!is_dir($saveDir)) {
|
||||
mkdir($saveDir, 0755, true);
|
||||
}
|
||||
|
||||
$filename = md5($url) . '.jpg';
|
||||
$savePath = rtrim($saveDir, '/') . '/' . $filename;
|
||||
|
||||
// 如果文件已存在,直接返回
|
||||
if (file_exists($savePath)) {
|
||||
return $savePath;
|
||||
}
|
||||
|
||||
// 使用 curl 下载图片
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'User-Agent: ' . self::DEFAULT_HEADERS['User-Agent'],
|
||||
'Referer: ' . self::DEFAULT_HEADERS['Referer'],
|
||||
],
|
||||
]);
|
||||
|
||||
$data = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || $data === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
file_put_contents($savePath, $data);
|
||||
return $savePath;
|
||||
} catch (\Exception $e) {
|
||||
error_log('下载图片失败: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载图片
|
||||
*
|
||||
* @param array $urls 图片URL数组
|
||||
* @param string $saveDir 保存目录
|
||||
* @return array [url => localPath]
|
||||
*/
|
||||
public function downloadImages(array $urls, string $saveDir): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($urls as $url) {
|
||||
$url = trim($url);
|
||||
if (empty($url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理协议相对URL
|
||||
if (str_starts_with($url, '//')) {
|
||||
$url = 'https:' . $url;
|
||||
}
|
||||
|
||||
$localPath = $this->downloadImage($url, $saveDir);
|
||||
$result[$url] = $localPath;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
<?php
|
||||
|
||||
namespace app\Service;
|
||||
|
||||
use app\Model\UsedCar;
|
||||
use app\Model\UsedCarPdfLog;
|
||||
use app\Model\User;
|
||||
use support\Db;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 二手车业务服务
|
||||
*/
|
||||
class UsedCarService
|
||||
{
|
||||
private UsedCarCrawlService $crawlService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->crawlService = new UsedCarCrawlService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二手车列表
|
||||
*
|
||||
* @param int $userId 用户ID
|
||||
* @param string $type 列表类型: all-全部, self-我创建的
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页数量
|
||||
* @param string|null $keyword 搜索关键词
|
||||
* @param int|null $status 状态筛选
|
||||
* @return array
|
||||
*/
|
||||
public function getList(int $userId, string $type = 'self', int $page = 1, int $pageSize = 20, ?string $keyword = null, ?int $status = null): array
|
||||
{
|
||||
$query = UsedCar::query();
|
||||
|
||||
// 筛选条件
|
||||
switch ($type) {
|
||||
case 'self':
|
||||
$query->where('user_id', $userId);
|
||||
break;
|
||||
case 'all':
|
||||
// 管理员可以查看所有
|
||||
break;
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if ($status !== null) {
|
||||
$query->where('status', $status);
|
||||
} else {
|
||||
// 默认不显示已删除的
|
||||
$query->where('status', '!=', UsedCar::STATUS_DELETED);
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
if ($keyword) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('product_name', 'like', "%{$keyword}%")
|
||||
->orWhere('code', 'like', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
$total = $query->count();
|
||||
|
||||
// 分页查询
|
||||
$list = $query->orderBy('sort', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->offset(($page - 1) * $pageSize)
|
||||
->limit($pageSize)
|
||||
->get();
|
||||
|
||||
// 格式化数据
|
||||
$formattedList = $list->map(function ($item) {
|
||||
return $this->formatListItem($item);
|
||||
})->toArray();
|
||||
|
||||
return [
|
||||
'list' => $formattedList,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二手车详情
|
||||
*
|
||||
* @param int $id 二手车ID
|
||||
* @param int $userId 当前用户ID
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getDetail(int $id, int $userId): array
|
||||
{
|
||||
$car = UsedCar::with(['pdfLogs', 'user'])->findOrFail($id);
|
||||
|
||||
// 格式化数据
|
||||
return $this->formatDetailItem($car);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建二手车记录
|
||||
*
|
||||
* @param array $data
|
||||
* @param int $userId
|
||||
* @return UsedCar
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function create(array $data, int $userId): UsedCar
|
||||
{
|
||||
// 验证数据
|
||||
$this->validateCarData($data);
|
||||
|
||||
return Db::connection()->transaction(function () use ($data, $userId) {
|
||||
// 获取用户信息
|
||||
$user = User::findOrFail($userId);
|
||||
|
||||
// 生成唯一编号
|
||||
$code = $this->generateCode($userId, $user->name ?? '');
|
||||
|
||||
// 处理图片
|
||||
$images = $data['images'] ?? [];
|
||||
if (!is_array($images)) {
|
||||
$images = explode(',', $images);
|
||||
}
|
||||
$images = array_filter($images);
|
||||
|
||||
// 处理附件
|
||||
$attachment = $data['attachment'] ?? null;
|
||||
if (is_array($attachment)) {
|
||||
$attachment = json_encode($attachment, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// 检查source_url是否已存在
|
||||
if (!empty($data['source_url'])) {
|
||||
$exists = UsedCar::where('source_url', $data['source_url'])
|
||||
->where('user_id', $userId)
|
||||
->exists();
|
||||
if ($exists) {
|
||||
throw new \Exception('该车源已收录');
|
||||
}
|
||||
}
|
||||
|
||||
// 创建记录
|
||||
$car = UsedCar::create([
|
||||
'code' => $code,
|
||||
'user_id' => $userId,
|
||||
'role' => $user->role ?? 1,
|
||||
'product_name' => $data['product_name'],
|
||||
'vin' => $data['vin'] ?? null,
|
||||
'price' => $this->parsePrice($data['price'] ?? 0),
|
||||
'source_place' => $data['source_place'] ?? null,
|
||||
'plate_place' => $data['plate_place'] ?? null,
|
||||
'plate_time' => $data['plate_time'] ?? null,
|
||||
'displacement' => $data['displacement'] ?? null,
|
||||
'mileage' => $this->parseMileage($data['mileage'] ?? 0),
|
||||
'power' => $this->parsePower($data['power'] ?? ''),
|
||||
'body_color' => $data['body_color'] ?? null,
|
||||
'images' => implode(',', $images),
|
||||
'condition_desc' => $data['condition_desc'] ?? null,
|
||||
'attachment' => $attachment,
|
||||
'source_url' => $data['source_url'] ?? null,
|
||||
'crawl_title' => $data['crawl_title'] ?? null,
|
||||
'crawl_brand' => $data['crawl_brand'] ?? null,
|
||||
'crawl_brand_id' => $data['crawl_brand_id'] ?? 0,
|
||||
'crawl_series' => $data['crawl_series'] ?? null,
|
||||
'crawl_series_id' => $data['crawl_series_id'] ?? 0,
|
||||
'crawl_year' => $data['crawl_year'] ?? null,
|
||||
'crawl_msrp' => $this->parsePrice($data['crawl_msrp'] ?? 0),
|
||||
'crawl_car_id' => $data['crawl_car_id'] ?? 0,
|
||||
'status' => UsedCar::STATUS_NORMAL,
|
||||
'sort' => $data['sort'] ?? 0,
|
||||
]);
|
||||
|
||||
return $car;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新二手车记录
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $data
|
||||
* @param int $userId
|
||||
* @return UsedCar
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function update(int $id, array $data, int $userId): UsedCar
|
||||
{
|
||||
$car = UsedCar::findOrFail($id);
|
||||
|
||||
// 检查权限
|
||||
if ($car->user_id !== $userId) {
|
||||
throw new \Exception('无权编辑此记录');
|
||||
}
|
||||
|
||||
// 验证数据
|
||||
$this->validateCarData($data);
|
||||
|
||||
// 处理图片
|
||||
if (isset($data['images'])) {
|
||||
$images = is_array($data['images']) ? $data['images'] : explode(',', $data['images']);
|
||||
$data['images'] = implode(',', array_filter($images));
|
||||
}
|
||||
|
||||
// 处理附件
|
||||
if (isset($data['attachment']) && is_array($data['attachment'])) {
|
||||
$data['attachment'] = json_encode($data['attachment'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// 处理价格和里程
|
||||
if (isset($data['price'])) {
|
||||
$data['price'] = $this->parsePrice($data['price']);
|
||||
}
|
||||
if (isset($data['mileage'])) {
|
||||
$data['mileage'] = $this->parseMileage($data['mileage']);
|
||||
}
|
||||
if (isset($data['power'])) {
|
||||
$data['power'] = $this->parsePower($data['power']);
|
||||
}
|
||||
if (isset($data['crawl_msrp'])) {
|
||||
$data['crawl_msrp'] = $this->parsePrice($data['crawl_msrp']);
|
||||
}
|
||||
|
||||
// 只更新允许的字段
|
||||
$fillable = [
|
||||
'product_name', 'vin', 'price', 'source_place', 'plate_place',
|
||||
'plate_time', 'displacement', 'mileage', 'power', 'body_color',
|
||||
'images', 'condition_desc', 'attachment',
|
||||
];
|
||||
|
||||
foreach ($fillable as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$car->$field = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
$car->save();
|
||||
return $car;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除二手车记录(软删除)
|
||||
*
|
||||
* @param int $id
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(int $id, int $userId): bool
|
||||
{
|
||||
$car = UsedCar::findOrFail($id);
|
||||
|
||||
// 检查权限
|
||||
if ($car->user_id !== $userId) {
|
||||
throw new \Exception('无权删除此记录');
|
||||
}
|
||||
|
||||
$car->status = UsedCar::STATUS_DELETED;
|
||||
return $car->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态
|
||||
*
|
||||
* @param int $id
|
||||
* @param int $status
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateStatus(int $id, int $status, int $userId): bool
|
||||
{
|
||||
$car = UsedCar::findOrFail($id);
|
||||
|
||||
// 检查权限
|
||||
if ($car->user_id !== $userId) {
|
||||
throw new \Exception('无权修改此记录');
|
||||
}
|
||||
|
||||
$car->status = $status;
|
||||
return $car->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* URL抓取车辆信息
|
||||
*
|
||||
* @param string $url
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function crawlByUrl(string $url): array
|
||||
{
|
||||
$data = $this->crawlService->crawlByUrl($url);
|
||||
|
||||
// 下载图片到临时目录
|
||||
$tempDir = runtime_path('tmp/usedcar_images/');
|
||||
$images = $data['images'] ?? [];
|
||||
|
||||
return [
|
||||
'product_name' => $data['title'] ?? '',
|
||||
'price' => $this->parsePrice($data['price'] ?? 0),
|
||||
'source_place' => $data['source_place'] ?? '',
|
||||
'plate_time' => $data['plate_time'] ?? '',
|
||||
'displacement' => $data['displacement'] ?? '',
|
||||
'mileage' => $data['mileage'] ?? 0,
|
||||
'power' => $data['power'] ?? '',
|
||||
'body_color' => $data['body_color'] ?? '',
|
||||
'images' => $images,
|
||||
'condition_desc' => $data['condition_desc'] ?? '',
|
||||
'source_url' => $data['source_url'] ?? $url,
|
||||
'crawl_title' => $data['title'] ?? '',
|
||||
'crawl_brand' => $data['brand_name'] ?? '',
|
||||
'crawl_brand_id' => $data['brand_id'] ?? 0,
|
||||
'crawl_series' => $data['series_name'] ?? '',
|
||||
'crawl_series_id' => $data['series_id'] ?? 0,
|
||||
'crawl_year' => $data['year'] ?? '',
|
||||
'crawl_car_id' => $data['car_id'] ?? 0,
|
||||
'used_car_id' => $data['used_car_id'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一编号
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $userName
|
||||
* @return string
|
||||
*/
|
||||
private function generateCode(int $userId, string $userName): string
|
||||
{
|
||||
// 获取用户名拼音首字母
|
||||
$nameInitials = $this->getNameInitials($userName);
|
||||
$userCode = str_pad($userId, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
// 从Redis获取今日计数
|
||||
$redisKey = 'used_car_count_' . $userId;
|
||||
$todayStart = strtotime('today');
|
||||
|
||||
// 检查数据库中今日已创建的数量
|
||||
$count = UsedCar::where('user_id', $userId)
|
||||
->where('created_at', '>=', $todayStart)
|
||||
->count();
|
||||
|
||||
// 从Redis获取缓存计数
|
||||
$cachedCount = Redis::get($redisKey, 0);
|
||||
$count = max($count, $cachedCount) + 1;
|
||||
|
||||
// 更新Redis缓存(24小时过期)
|
||||
Redis::setex($redisKey, 86400, $count);
|
||||
|
||||
$countCode = str_pad($count, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
return $nameInitials . $userCode . date('Ymd') . $countCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中文名拼音首字母
|
||||
*/
|
||||
private function getNameInitials(string $name): string
|
||||
{
|
||||
$initials = '';
|
||||
$len = mb_strlen($name);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$char = mb_substr($name, $i, 1);
|
||||
$initials .= $this->getFirstChar($char);
|
||||
}
|
||||
|
||||
return strtoupper($initials);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个汉字的首字母
|
||||
*/
|
||||
private function getFirstChar(string $char): string
|
||||
{
|
||||
// 英文字母直接返回
|
||||
if (preg_match('/^[a-zA-Z]$/', $char)) {
|
||||
return strtoupper($char);
|
||||
}
|
||||
|
||||
// 转换为GB2312编码
|
||||
$s = @iconv('UTF-8', 'GB2312//IGNORE', $char);
|
||||
if ($s === false || strlen($s) < 2) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$code = ord($s[0]) * 256 + ord($s[1]) - 65536;
|
||||
|
||||
$map = [
|
||||
[-20319, -20284, 'A'], [-20283, -19776, 'B'], [-19775, -19219, 'C'],
|
||||
[-19218, -18711, 'D'], [-18710, -18527, 'E'], [-18526, -18240, 'F'],
|
||||
[-18239, -17923, 'G'], [-17922, -17418, 'H'], [-17417, -16475, 'J'],
|
||||
[-16474, -16213, 'K'], [-16212, -15641, 'L'], [-15640, -15166, 'M'],
|
||||
[-15165, -14923, 'N'], [-14922, -14915, 'O'], [-14914, -14631, 'P'],
|
||||
[-14630, -14150, 'Q'], [-14149, -14091, 'R'], [-14090, -13319, 'S'],
|
||||
[-13318, -12839, 'T'], [-12838, -12557, 'W'], [-12556, -11848, 'X'],
|
||||
[-11847, -11056, 'Y'], [-11055, -10247, 'Z']
|
||||
];
|
||||
|
||||
foreach ($map as [$start, $end, $letter]) {
|
||||
if ($code >= $start && $code <= $end) {
|
||||
return $letter;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析价格(单位统一转换为分)
|
||||
*/
|
||||
private function parsePrice($price): int
|
||||
{
|
||||
$price = floatval(str_replace(['万', ','], '', $price));
|
||||
// 如果小于200,说明是以万为单位
|
||||
if ($price < 200 && $price > 0) {
|
||||
$price = $price * 10000;
|
||||
}
|
||||
return (int)($price * 100); // 转换为分
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析里程(移除单位)
|
||||
*/
|
||||
private function parseMileage($mileage): int
|
||||
{
|
||||
$mileage = str_replace(['万', 'K', 'M', 'k', 'm', '公里'], '', $mileage);
|
||||
return (int)floatval($mileage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析马力(移除单位)
|
||||
*/
|
||||
private function parsePower($power): string
|
||||
{
|
||||
return str_replace(['P', 'S', 'p', 's', '马力', 'Ps', 'PS'], '', $power);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证车辆数据
|
||||
*/
|
||||
private function validateCarData(array $data): void
|
||||
{
|
||||
if (empty($data['product_name'])) {
|
||||
throw new \Exception('产品名称不能为空');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化列表项
|
||||
*/
|
||||
private function formatListItem(UsedCar $car): array
|
||||
{
|
||||
$data = $car->toArray();
|
||||
|
||||
// 格式化时间
|
||||
if ($car->created_at) {
|
||||
$data['created_at'] = $car->created_at->toDateTimeString();
|
||||
}
|
||||
if ($car->updated_at) {
|
||||
$data['updated_at'] = $car->updated_at->toDateTimeString();
|
||||
}
|
||||
|
||||
// 处理图片
|
||||
$data['images_array'] = [];
|
||||
if (!empty($car->images)) {
|
||||
$data['images_array'] = explode(',', $car->images);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化详情项
|
||||
*/
|
||||
private function formatDetailItem(UsedCar $car): array
|
||||
{
|
||||
$data = $car->toArray();
|
||||
|
||||
// 格式化时间
|
||||
if ($car->created_at) {
|
||||
$data['created_at'] = $car->created_at->toDateTimeString();
|
||||
}
|
||||
if ($car->updated_at) {
|
||||
$data['updated_at'] = $car->updated_at->toDateTimeString();
|
||||
}
|
||||
|
||||
// 处理图片
|
||||
$data['images_array'] = [];
|
||||
if (!empty($car->images)) {
|
||||
$data['images_array'] = explode(',', $car->images);
|
||||
}
|
||||
|
||||
// 处理附件
|
||||
$data['attachment_array'] = [];
|
||||
if (!empty($car->attachment)) {
|
||||
$attachment = json_decode($car->attachment, true);
|
||||
if (is_array($attachment)) {
|
||||
$data['attachment_array'] = $attachment;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加用户信息
|
||||
if ($car->user) {
|
||||
$data['user'] = [
|
||||
'id' => $car->user->id,
|
||||
'name' => $car->user->name,
|
||||
];
|
||||
}
|
||||
|
||||
// PDF生成日志
|
||||
if ($car->pdfLogs) {
|
||||
$data['pdf_logs'] = $car->pdfLogs->map(function ($log) {
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'price_snapshot' => $log->price_snapshot,
|
||||
'pdf_path' => $log->pdf_path,
|
||||
'created_at' => $log->created_at ? $log->created_at->toDateTimeString() : null,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"webman/database": "^2.1",
|
||||
"symfony/var-dumper": "^8.0",
|
||||
"webman/redis": "^2.1"
|
||||
"webman/redis": "^2.1",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"jaeger/querylist": "^4.4",
|
||||
"mpdf/mpdf": "^8.2",
|
||||
"workerman/crontab": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.5",
|
||||
|
||||
Generated
+1229
-1643
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* 自定义进程配置
|
||||
* 用于注册定时任务等自定义进程
|
||||
*/
|
||||
|
||||
return [
|
||||
// 定时任务进程
|
||||
'task' => [
|
||||
'handler' => app\process\Task::class
|
||||
],
|
||||
];
|
||||
@@ -62,6 +62,22 @@ Route::group('/api/v1/business-trip', function () {
|
||||
Route::post('/approve/{id}', [app\controller\BusinessTripController::class, 'approve']);
|
||||
})->middleware($authMiddleware);
|
||||
|
||||
// 二手车路由(需要认证)
|
||||
Route::group('/api/v1/usedcar', function () {
|
||||
Route::get('/list', [app\controller\UsedCarController::class, 'list']);
|
||||
Route::get('/detail', [app\controller\UsedCarController::class, 'detail']);
|
||||
Route::post('/create', [app\controller\UsedCarController::class, 'create']);
|
||||
Route::post('/update', [app\controller\UsedCarController::class, 'update']);
|
||||
Route::post('/delete', [app\controller\UsedCarController::class, 'delete']);
|
||||
Route::post('/status', [app\controller\UsedCarController::class, 'updateStatus']);
|
||||
Route::post('/crawl/url', [app\controller\UsedCarController::class, 'crawlByUrl']);
|
||||
Route::post('/crawl/mobile', [app\controller\UsedCarController::class, 'crawlMobileUrl']);
|
||||
Route::post('/pdf/generate', [app\controller\UsedCarController::class, 'generatePdf']);
|
||||
Route::get('/pdf/download', [app\controller\UsedCarController::class, 'downloadPdf']);
|
||||
Route::get('/pdf/url', [app\controller\UsedCarController::class, 'getPdfUrl']);
|
||||
Route::get('/pdf/preview', [app\controller\UsedCarController::class, 'previewPdf']);
|
||||
})->middleware($authMiddleware);
|
||||
|
||||
// 默认路由
|
||||
Route::fallback(function () {
|
||||
return json(['code' => 404, 'message' => 'Not Found']);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
-- 二手车相关数据表
|
||||
-- 创建时间: 2026-03-05
|
||||
|
||||
-- 二手车主表
|
||||
CREATE TABLE IF NOT EXISTS `used_car` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`code` varchar(100) NOT NULL DEFAULT '' COMMENT '唯一编号(用户拼音首字母+用户ID+日期+序号)',
|
||||
`user_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID',
|
||||
`role` tinyint(1) NOT NULL DEFAULT 1 COMMENT '用户角色:1管理员 2普通用户',
|
||||
`product_name` varchar(255) NOT NULL DEFAULT '' COMMENT '产品名称/车辆标题',
|
||||
`vin` varchar(50) DEFAULT NULL COMMENT '车辆识别码',
|
||||
`price` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '价格(单位:分)',
|
||||
`source_place` varchar(100) DEFAULT NULL COMMENT '车源地',
|
||||
`plate_place` varchar(100) DEFAULT NULL COMMENT '上牌地点',
|
||||
`plate_time` varchar(50) DEFAULT NULL COMMENT '上牌时间',
|
||||
`displacement` varchar(50) DEFAULT NULL COMMENT '排量',
|
||||
`mileage` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '里程(单位:公里)',
|
||||
`power` varchar(50) DEFAULT NULL COMMENT '马力',
|
||||
`body_color` varchar(50) DEFAULT NULL COMMENT '车身颜色',
|
||||
`images` text DEFAULT NULL COMMENT '图片(逗号分隔的OSS路径)',
|
||||
`condition_desc` text DEFAULT NULL COMMENT '车况介绍',
|
||||
`attachment` text DEFAULT NULL COMMENT '附件(JSON格式:[{name:, src:}])',
|
||||
`pdf_src` varchar(255) DEFAULT NULL COMMENT 'PDF文件路径',
|
||||
`source_url` text DEFAULT NULL COMMENT '源URL(抓取来源)',
|
||||
`crawl_title` varchar(255) DEFAULT NULL COMMENT '抓取标题',
|
||||
`crawl_brand` varchar(100) DEFAULT NULL COMMENT '抓取品牌',
|
||||
`crawl_brand_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '抓取品牌ID',
|
||||
`crawl_series` varchar(100) DEFAULT NULL COMMENT '抓取车系',
|
||||
`crawl_series_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '抓取车系ID',
|
||||
`crawl_year` varchar(10) DEFAULT NULL COMMENT '抓取年份',
|
||||
`crawl_msrp` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '抓取指导价(单位:分)',
|
||||
`crawl_car_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '抓取车型ID',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1正常 2归档 3删除',
|
||||
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_code` (`code`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='二手车信息表';
|
||||
|
||||
-- 二手车PDF生成日志表
|
||||
CREATE TABLE IF NOT EXISTS `used_car_pdf_log` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID',
|
||||
`used_car_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '二手车ID',
|
||||
`price_snapshot` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '价格快照(单位:分)',
|
||||
`pdf_path` varchar(255) NOT NULL DEFAULT '' COMMENT 'PDF路径',
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_used_car_id` (`used_car_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='二手车PDF生成日志表';
|
||||
@@ -900539,3 +900539,61 @@ INSERT INTO `users_copy1` VALUES (74, '263364', 3, '李永春', 'https://s3-imfi
|
||||
INSERT INTO `users_copy1` VALUES (75, '698484', 3, '曲江丽', 'https://s1-imfile.feishucdn.com/static-resource/v1/v3_00uh_6412ff58-77d6-4e5f-b35e-6b1a76ed31eg~?image_size=240x240&cut_type=&quality=&format=image&sticker_format=.webp', NULL, 'qujiangli@paikacar.com', '18339956922', '$2y$10$j9w0jkEYliIPbGbiPK5MSOhqGwwzY1Enf/koZC2MVb4JrXSFUhqDq', 1, 1, 'on_8f01d5bee7af07ce3de5e31df7075c99', 2, 'x_server/user_signature/20260210/6ad00820e9be180db7ac4aa729e4b6e9_698ad1464aae9.png', '2026-02-10 14:25:49', '2026-02-26 16:18:51');
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- 二手车相关数据表 (015_create_used_car_tables.sql)
|
||||
-- 二手车相关数据表
|
||||
-- 创建时间: 2026-03-05
|
||||
|
||||
-- 二手车主表
|
||||
CREATE TABLE IF NOT EXISTS `used_car` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`code` varchar(100) NOT NULL DEFAULT '' COMMENT '唯一编号(用户拼音首字母+用户ID+日期+序号)',
|
||||
`user_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID',
|
||||
`role` tinyint(1) NOT NULL DEFAULT 1 COMMENT '用户角色:1管理员 2普通用户',
|
||||
`product_name` varchar(255) NOT NULL DEFAULT '' COMMENT '产品名称/车辆标题',
|
||||
`vin` varchar(50) DEFAULT NULL COMMENT '车辆识别码',
|
||||
`price` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '价格(单位:分)',
|
||||
`source_place` varchar(100) DEFAULT NULL COMMENT '车源地',
|
||||
`plate_place` varchar(100) DEFAULT NULL COMMENT '上牌地点',
|
||||
`plate_time` varchar(50) DEFAULT NULL COMMENT '上牌时间',
|
||||
`displacement` varchar(50) DEFAULT NULL COMMENT '排量',
|
||||
`mileage` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '里程(单位:公里)',
|
||||
`power` varchar(50) DEFAULT NULL COMMENT '马力',
|
||||
`body_color` varchar(50) DEFAULT NULL COMMENT '车身颜色',
|
||||
`images` text DEFAULT NULL COMMENT '图片(逗号分隔的OSS路径)',
|
||||
`condition_desc` text DEFAULT NULL COMMENT '车况介绍',
|
||||
`attachment` text DEFAULT NULL COMMENT '附件(JSON格式:[{name:, src:}])',
|
||||
`pdf_src` varchar(255) DEFAULT NULL COMMENT 'PDF文件路径',
|
||||
`source_url` text DEFAULT NULL COMMENT '源URL(抓取来源)',
|
||||
`crawl_title` varchar(255) DEFAULT NULL COMMENT '抓取标题',
|
||||
`crawl_brand` varchar(100) DEFAULT NULL COMMENT '抓取品牌',
|
||||
`crawl_brand_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '抓取品牌ID',
|
||||
`crawl_series` varchar(100) DEFAULT NULL COMMENT '抓取车系',
|
||||
`crawl_series_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '抓取车系ID',
|
||||
`crawl_year` varchar(10) DEFAULT NULL COMMENT '抓取年份',
|
||||
`crawl_msrp` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '抓取指导价(单位:分)',
|
||||
`crawl_car_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '抓取车型ID',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1正常 2归档 3删除',
|
||||
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_code` (`code`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='二手车信息表';
|
||||
|
||||
-- 二手车PDF生成日志表
|
||||
CREATE TABLE IF NOT EXISTS `used_car_pdf_log` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID',
|
||||
`used_car_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '二手车ID',
|
||||
`price_snapshot` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '价格快照(单位:分)',
|
||||
`pdf_path` varchar(255) NOT NULL DEFAULT '' COMMENT 'PDF路径',
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_used_car_id` (`used_car_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='二手车PDF生成日志表';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: "sun-exta", "Noto Sans SC", sans-serif;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
border: 1px solid #e5e5e5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.qrcode-wrap {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrcode-wrap img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 20px;
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2><?= $contactField['title'] ?></h2>
|
||||
|
||||
<div class="card">
|
||||
<?php if (!empty($user['name'])): ?>
|
||||
<div class="row"><span class="label"><?= $contactField['name'] ?>:</span><?= $user['name'] ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($user['email'])): ?>
|
||||
<div class="row"><span class="label"><?= $contactField['email'] ?>:</span><?= $user['email'] ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($user['phone'])): ?>
|
||||
<div class="row"><span class="label"><?= $contactField['phone'] ?>:</span><?= $user['phone'] ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($user['whatsapp'])): ?>
|
||||
<div class="row"><span class="label">WhatsApp:</span><?= $user['whatsapp'] ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($user['social']) && !empty($user['social_code'])): ?>
|
||||
<div class="qrcode-wrap">
|
||||
<div><?= $contactField['code'] ?></div>
|
||||
<img style = "width: 270px;height: 270px;margin-top: 30px;" src="<?= $user['social_code'] ?>">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="tip">
|
||||
<?= $contactField['left'] ?> | <?= $contactField['right'] ?>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: "sun-exta", "Noto Sans SC", sans-serif;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
margin: 20px 0 10px;
|
||||
border-left: 4px solid #1677ff;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.info-table td.label {
|
||||
width: 120px;
|
||||
color: #666;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 16px;
|
||||
color: #d4380d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
width: 48%;
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<h1><?= htmlspecialchars($car['product_name']) ?></h1>
|
||||
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td class="label"><?= $field['price'] ?></td>
|
||||
<td class="price"><?= $field['currency'].' ' ?><?= number_format($car['price'], 2) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><?= $field['trade'] ?></td>
|
||||
<td><?= $car['trade'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><?= $field['plate_time'] ?></td>
|
||||
<td><?= $car['plate_time'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><?= $field['displacement'] ?></td>
|
||||
<td><?= $car['displacement'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><?= $field['mileage'] ?></td>
|
||||
<td><?= $car['mileage'].'km' ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><?= $field['power'] ?></td>
|
||||
<td><?= $car['power'] ?> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><?= $field['body_color'] ?></td>
|
||||
<td><?= $car['body_color'] ?: '-' ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><?= $field['created_at'] ?></td>
|
||||
<td><?= $car['created_at'] ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if (!empty($car['condition_desc'])): ?>
|
||||
<h2><?= $field['condition_desc'] ?></h2>
|
||||
<div class="desc">
|
||||
<?= nl2br(htmlspecialchars($car['condition_desc'])) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<div class="image-list">
|
||||
<table width="100%" cellpadding="6" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" style="
|
||||
font-size:28px;
|
||||
font-weight:bold;
|
||||
text-align:left;
|
||||
padding:12px 0;
|
||||
border-left:4px solid #1677ff;
|
||||
padding-left:10px;
|
||||
background:#fff;
|
||||
">
|
||||
<div style="
|
||||
font-size:28px;
|
||||
font-weight:bold;
|
||||
padding:10px 0;
|
||||
">
|
||||
<?= $field['images'] ?>
|
||||
</div>
|
||||
</th >
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($car['images'] as $i => $img): ?>
|
||||
<?php if ($i % 2 === 0): ?>
|
||||
<tr>
|
||||
<?php endif; ?>
|
||||
|
||||
<td width="50%" align="center">
|
||||
<img src="<?= $img ?>" style="width:100%; border-radius:4px;">
|
||||
</td>
|
||||
|
||||
<?php if ($i % 2 === 1): ?>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user