Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| baa424c841 | |||
| 8a056e2295 | |||
| 50f28e45e4 | |||
| 1720aec385 | |||
| 0d895688c3 | |||
| 9c7efff01f |
@@ -52,7 +52,7 @@ abstract class BaseController
|
||||
}
|
||||
|
||||
// 解析 token
|
||||
$payload = app\util\JwtUtil::decode($token);
|
||||
$payload = \app\Util\JwtUtil::decode($token);
|
||||
if (!$payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\Service\UploadService;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
|
||||
/**
|
||||
* 文件上传控制器
|
||||
*/
|
||||
class UploadController extends BaseController
|
||||
{
|
||||
private UploadService $uploadService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uploadService = new UploadService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用文件上传接口
|
||||
*/
|
||||
public function upload(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$userId = $this->getUserId($request); // 可选,没有token则为null
|
||||
$partition = $request->post('partition', 'other');
|
||||
$category = $request->post('category', 'general');
|
||||
|
||||
$result = $this->uploadService->upload($request, $partition, $category, $userId);
|
||||
|
||||
return $this->success($result, '上传成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传接口
|
||||
*/
|
||||
public function uploadImage(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$userId = $this->getUserId($request); // 可选,没有token则为null
|
||||
$category = $request->post('category', 'general');
|
||||
|
||||
$result = $this->uploadService->uploadImage($request, $category, $userId);
|
||||
|
||||
return $this->success($result, '上传成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 二手车图片上传接口
|
||||
*/
|
||||
public function uploadUsedCarImage(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$userId = $this->getUserId($request); // 可选,没有token则为null
|
||||
|
||||
$result = $this->uploadService->uploadUsedCarImage($request, $userId);
|
||||
|
||||
return $this->success($result, '上传成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传图片(二手车专用)
|
||||
*/
|
||||
public function batchUploadImages(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$userId = $this->getUserId($request); // 可选,没有token则为null
|
||||
$files = $request->files();
|
||||
|
||||
if (empty($files)) {
|
||||
return $this->error('请选择要上传的文件');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($files['files'] ?? [] as $index => $file) {
|
||||
try {
|
||||
// 创建临时请求对象处理单个文件
|
||||
$tempRequest = new Request(
|
||||
$request->method(),
|
||||
$request->path(),
|
||||
$request->headers(),
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$result = $this->uploadService->uploadUsedCarImage($tempRequest, $userId);
|
||||
$results[] = $result;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = [
|
||||
'index' => $index,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'success' => $results,
|
||||
'errors' => $errors,
|
||||
'total' => count($results),
|
||||
'failed' => count($errors),
|
||||
], '批量上传完成');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
public function deleteFile(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$userId = $this->getUserId($request); // 可选,用于权限验证
|
||||
$fileId = (int)$request->post('id');
|
||||
|
||||
if ($fileId <= 0) {
|
||||
return $this->error('无效的文件ID');
|
||||
}
|
||||
|
||||
$this->uploadService->deleteFile($fileId, $userId);
|
||||
|
||||
return $this->success(null, '删除成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*/
|
||||
public function getFileInfo(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$fileId = (int)$request->get('id');
|
||||
|
||||
if ($fileId <= 0) {
|
||||
return $this->error('无效的文件ID');
|
||||
}
|
||||
|
||||
$info = $this->uploadService->getFileInfo($fileId);
|
||||
|
||||
return $this->success($info);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分区配置
|
||||
*/
|
||||
public function getPartitionConfig(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$configs = UploadService::getPartitionConfigs();
|
||||
|
||||
return $this->success($configs);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词搜索懂车帝车型数据
|
||||
*/
|
||||
public function crawlKeyword(Request $request): Response
|
||||
{
|
||||
$this->requireAuth($request);
|
||||
$keyword = $request->post('keyword') ?: $request->get('keyword');
|
||||
|
||||
if (empty($keyword)) {
|
||||
return $this->error('关键词不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
$crawlService = new \app\Service\UsedCarCrawlService();
|
||||
$data = $crawlService->searchByKeyword($keyword);
|
||||
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,115 @@
|
||||
<?php
|
||||
|
||||
namespace app\Model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
* 上传文件记录模型
|
||||
*/
|
||||
class UploadedFiles extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'uploaded_files';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
/**
|
||||
* 可批量赋值的字段
|
||||
*/
|
||||
protected $fillable = [
|
||||
'file_name',
|
||||
'file_path',
|
||||
'file_size',
|
||||
'file_type',
|
||||
'category',
|
||||
'original_name',
|
||||
'extension',
|
||||
'partition',
|
||||
'upload_ip',
|
||||
'user_agent',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* 上传分区配置
|
||||
*/
|
||||
public static array $uploadPartitions = [
|
||||
'image' => [
|
||||
'path' => 'uploads/images',
|
||||
'max_size' => 10 * 1024 * 1024, // 10MB
|
||||
'allowed_types' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
'extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp']
|
||||
],
|
||||
'avatar' => [
|
||||
'path' => 'uploads/avatars',
|
||||
'max_size' => 5 * 1024 * 1024, // 5MB
|
||||
'allowed_types' => ['image/jpeg', 'image/png', 'image/webp'],
|
||||
'extensions' => ['jpg', 'jpeg', 'png', 'webp']
|
||||
],
|
||||
'document' => [
|
||||
'path' => 'uploads/documents',
|
||||
'max_size' => 20 * 1024 * 1024, // 20MB
|
||||
'allowed_types' => [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
],
|
||||
'extensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx']
|
||||
],
|
||||
'usedcar' => [
|
||||
'path' => 'uploads/usedcar',
|
||||
'max_size' => 10 * 1024 * 1024, // 10MB
|
||||
'allowed_types' => ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
|
||||
'extensions' => ['jpg', 'jpeg', 'png', 'webp', 'gif']
|
||||
],
|
||||
'other' => [
|
||||
'path' => 'uploads/others',
|
||||
'max_size' => 5 * 1024 * 1024, // 5MB
|
||||
'allowed_types' => [],
|
||||
'extensions' => []
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联用户
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分区配置
|
||||
*/
|
||||
public static function getPartitionConfig(string $partition): ?array
|
||||
{
|
||||
return self::$uploadPartitions[$partition] ?? self::$uploadPartitions['other'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分区配置
|
||||
*/
|
||||
public static function getAllPartitions(): array
|
||||
{
|
||||
return self::$uploadPartitions;
|
||||
}
|
||||
}
|
||||
@@ -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,326 @@
|
||||
<?php
|
||||
|
||||
namespace app\Service;
|
||||
|
||||
use app\Model\UploadedFiles;
|
||||
use Webman\Http\Request;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 文件上传服务
|
||||
* 支持本地存储,可扩展支持 OSS
|
||||
*/
|
||||
class UploadService
|
||||
{
|
||||
/**
|
||||
* 上传分区类型
|
||||
*/
|
||||
public const PARTITION_IMAGE = 'image';
|
||||
public const PARTITION_AVATAR = 'avatar';
|
||||
public const PARTITION_DOCUMENT = 'document';
|
||||
public const PARTITION_USEDCAR = 'usedcar';
|
||||
public const PARTITION_OTHER = 'other';
|
||||
|
||||
/**
|
||||
* 基础存储路径
|
||||
*/
|
||||
private string $basePath;
|
||||
|
||||
/**
|
||||
* 访问域名
|
||||
*/
|
||||
private string $domain;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->basePath = public_path(); // 存储到 public 目录
|
||||
$this->domain = $this->getDomain();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问域名
|
||||
*/
|
||||
private function getDomain(): string
|
||||
{
|
||||
// 从环境变量或配置获取
|
||||
$domain = getenv('MAIN_DOMAIN') ?: getenv('APP_URL') ?: '';
|
||||
if (empty($domain)) {
|
||||
$scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$domain = $scheme . '://' . $host;
|
||||
}
|
||||
return rtrim($domain, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $partition 上传分区 (image/avatar/document/usedcar/other)
|
||||
* @param string $category 文件分类 (可选)
|
||||
* @param int|null $userId 用户ID (可选)
|
||||
* @return array 上传结果
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function upload(Request $request, string $partition = self::PARTITION_OTHER, string $category = 'general', ?int $userId = null): array
|
||||
{
|
||||
$file = $request->file('file');
|
||||
|
||||
if (!$file || !$file->isValid()) {
|
||||
throw new \Exception('文件上传失败或无效');
|
||||
}
|
||||
|
||||
// 获取分区配置
|
||||
$config = UploadedFiles::getPartitionConfig($partition);
|
||||
|
||||
// 获取文件大小(必须在移动文件前获取)
|
||||
$fileSize = $file->getSize();
|
||||
$originalName = $file->getUploadName();
|
||||
|
||||
// 验证文件大小
|
||||
if ($fileSize > $config['max_size']) {
|
||||
$maxSizeMB = round($config['max_size'] / 1024 / 1024, 2);
|
||||
throw new \Exception("文件大小超过限制,最大允许 {$maxSizeMB}MB");
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
$mimeType = $file->getUploadMimeType();
|
||||
if (!empty($config['allowed_types']) && !in_array($mimeType, $config['allowed_types'])) {
|
||||
throw new \Exception('不支持的文件类型: ' . $mimeType);
|
||||
}
|
||||
|
||||
// 验证文件扩展名
|
||||
$extension = strtolower($file->getUploadExtension());
|
||||
if (!empty($config['extensions']) && !in_array($extension, $config['extensions'])) {
|
||||
throw new \Exception('不支持的文件扩展名: .' . $extension);
|
||||
}
|
||||
|
||||
// 生成存储路径
|
||||
$datePath = date('Ymd');
|
||||
$relativePath = '/' . $config['path'] . '/' . $datePath;
|
||||
$fullPath = $this->basePath . $relativePath;
|
||||
|
||||
// 创建目录
|
||||
if (!is_dir($fullPath)) {
|
||||
if (!mkdir($fullPath, 0755, true)) {
|
||||
throw new \Exception('创建存储目录失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
$fileName = $this->generateFileName($file, $extension);
|
||||
$filePath = $fullPath . '/' . $fileName;
|
||||
|
||||
// 移动上传文件
|
||||
if (!$file->move($filePath)) {
|
||||
throw new \Exception('文件保存失败');
|
||||
}
|
||||
|
||||
// 图片处理(压缩)
|
||||
if (str_starts_with($mimeType, 'image/')) {
|
||||
$this->processImage($filePath, $mimeType);
|
||||
}
|
||||
|
||||
// 记录到数据库
|
||||
$fileUrl = $relativePath . '/' . $fileName;
|
||||
$uploadIp = $request->getRealIp() ?? '0.0.0.0';
|
||||
$userAgent = $request->header('User-Agent', '');
|
||||
|
||||
$uploadedFile = UploadedFiles::create([
|
||||
'file_name' => $fileName,
|
||||
'file_path' => $fileUrl,
|
||||
'file_size' => $fileSize,
|
||||
'file_type' => $mimeType,
|
||||
'category' => $category,
|
||||
'original_name' => $originalName,
|
||||
'extension' => $extension,
|
||||
'partition' => $partition,
|
||||
'upload_ip' => $uploadIp,
|
||||
'user_agent' => $userAgent,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $uploadedFile->id,
|
||||
'file_name' => $fileName,
|
||||
'file_url' => $fileUrl,
|
||||
'full_url' => $this->domain . $fileUrl,
|
||||
'file_size' => $fileSize,
|
||||
'file_type' => $mimeType,
|
||||
'original_name' => $originalName,
|
||||
'partition' => $partition,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片(专用接口)
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param string $category 文件分类
|
||||
* @param int|null $userId 用户ID
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function uploadImage(Request $request, string $category = 'general', ?int $userId = null): array
|
||||
{
|
||||
return $this->upload($request, self::PARTITION_IMAGE, $category, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传二手车图片(专用接口)
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @param int|null $userId 用户ID
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function uploadUsedCarImage(Request $request, ?int $userId = null): array
|
||||
{
|
||||
return $this->upload($request, self::PARTITION_USEDCAR, 'usedcar', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* @param int $fileId 文件ID
|
||||
* @param int|null $userId 用户ID(用于权限验证)
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deleteFile(int $fileId, ?int $userId = null): bool
|
||||
{
|
||||
$file = UploadedFiles::find($fileId);
|
||||
|
||||
if (!$file) {
|
||||
throw new \Exception('文件不存在');
|
||||
}
|
||||
|
||||
// 权限验证:只能删除自己的文件(管理员除外)
|
||||
if ($userId !== null && $file->user_id != $userId) {
|
||||
throw new \Exception('无权删除此文件');
|
||||
}
|
||||
|
||||
// 删除物理文件
|
||||
$fullPath = $this->basePath . $file->file_path;
|
||||
if (file_exists($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
return $file->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*
|
||||
* @param int $fileId 文件ID
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getFileInfo(int $fileId): array
|
||||
{
|
||||
$file = UploadedFiles::find($fileId);
|
||||
|
||||
if (!$file) {
|
||||
throw new \Exception('文件不存在');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'file_name' => $file->file_name,
|
||||
'file_url' => $file->file_path,
|
||||
'full_url' => $this->domain . $file->file_path,
|
||||
'file_size' => $file->file_size,
|
||||
'file_type' => $file->file_type,
|
||||
'original_name' => $file->original_name,
|
||||
'partition' => $file->partition,
|
||||
'category' => $file->category,
|
||||
'created_at' => $file->created_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一文件名
|
||||
*/
|
||||
private function generateFileName($file, string $extension): string
|
||||
{
|
||||
$timestamp = time();
|
||||
$random = bin2hex(random_bytes(4));
|
||||
return $timestamp . '_' . $random . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片(压缩)
|
||||
*/
|
||||
private function processImage(string $filePath, string $mimeType): void
|
||||
{
|
||||
// 检查是否支持 imagick 扩展
|
||||
if (!extension_loaded('imagick')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$image = new \Imagick($filePath);
|
||||
|
||||
// 限制最大尺寸
|
||||
$maxWidth = 2000;
|
||||
$maxHeight = 2000;
|
||||
$image->thumbnailImage($maxWidth, $maxHeight, true);
|
||||
|
||||
// 自动方向修正
|
||||
if (method_exists($image, 'autoOrient')) {
|
||||
$image->autoOrient();
|
||||
}
|
||||
|
||||
// 根据类型处理
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
if (in_array($extension, ['png', 'jpeg', 'jpg'])) {
|
||||
// 转换为 WebP 并压缩
|
||||
$image->setImageFormat('webp');
|
||||
$image->setImageCompressionQuality(80);
|
||||
$webpPath = preg_replace('/\.(png|jpe?g)$/i', '.webp', $filePath);
|
||||
$image->writeImage($webpPath);
|
||||
|
||||
// 如果转换成功且文件名改变,删除原文件
|
||||
if ($webpPath !== $filePath && file_exists($webpPath)) {
|
||||
@unlink($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
$image->clear();
|
||||
$image->destroy();
|
||||
} catch (\Exception $e) {
|
||||
// 处理失败,保留原图
|
||||
error_log('图片处理失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
public static function formatFileSize(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= pow(1024, $pow);
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分区配置
|
||||
*/
|
||||
public static function getPartitionConfigs(): array
|
||||
{
|
||||
$configs = UploadedFiles::getAllPartitions();
|
||||
foreach ($configs as &$config) {
|
||||
$config['max_size_formatted'] = self::formatFileSize($config['max_size']);
|
||||
$config['allowed_types_formatted'] = implode(', ', $config['extensions']);
|
||||
}
|
||||
return $configs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过关键词搜索懂车帝车系信息
|
||||
*
|
||||
* @param string $keyword 搜索关键词(如 "奥迪A4")
|
||||
* @return array 品牌和车系信息
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
// 访问懂车帝搜索页面获取第一个结果的链接
|
||||
$searchUrl = "https://www.dongchedi.com/search?keyword=" . urlencode($keyword);
|
||||
|
||||
try {
|
||||
$html = $this->fetchHtml($searchUrl);
|
||||
$ql = QueryList::html($html);
|
||||
|
||||
// 查找"查看全部"按钮的链接
|
||||
$href = '';
|
||||
$ql->find('a.fold-btn')->each(function ($item) use (&$href) {
|
||||
$itemHref = $item->attr('href');
|
||||
if ($itemHref) {
|
||||
$href = $itemHref;
|
||||
}
|
||||
});
|
||||
|
||||
if (empty($href)) {
|
||||
throw new \Exception('未找到相关车系,请使用更精确的关键词');
|
||||
}
|
||||
|
||||
// 提取车系ID
|
||||
$seriesId = $this->extractSeriesId($href);
|
||||
if (!$seriesId) {
|
||||
throw new \Exception('无法解析车系ID');
|
||||
}
|
||||
|
||||
// 获取品牌和车系信息
|
||||
return $this->getBrandSeries($seriesId);
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('搜索失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取品牌和车系名称
|
||||
*
|
||||
* @param string $seriesId 车系ID
|
||||
* @return array 包含品牌和车系信息的数组
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getBrandSeries(string $seriesId): array
|
||||
{
|
||||
$url = "https://www.dongchedi.com/auto/series/" . $seriesId . "?anchorId=carModels";
|
||||
|
||||
try {
|
||||
$html = $this->fetchHtml($url);
|
||||
$ql = QueryList::html($html);
|
||||
|
||||
// 从面包屑导航中提取品牌和车系
|
||||
$breadcrumbs = [];
|
||||
$ql->find('div.breadcrumb_wrap__2GLeU span')->each(function ($item) use (&$breadcrumbs) {
|
||||
$breadcrumbs[] = trim($item->text());
|
||||
});
|
||||
|
||||
$brand = $breadcrumbs[2] ?? '';
|
||||
$series = $breadcrumbs[3] ?? '';
|
||||
|
||||
// 获取车型列表
|
||||
$models = $this->getSeriesModels($seriesId);
|
||||
|
||||
return [
|
||||
'brand' => $brand,
|
||||
'series' => $series,
|
||||
'series_id' => $seriesId,
|
||||
'models' => $models,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('获取车系信息失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取车系下的车型列表
|
||||
*
|
||||
* @param string $seriesId 车系ID
|
||||
* @return array 车型列表
|
||||
*/
|
||||
public function getSeriesModels(string $seriesId): array
|
||||
{
|
||||
$url = "https://www.dongchedi.com/auto/series/" . $seriesId . "?anchorId=carModels";
|
||||
$models = [];
|
||||
|
||||
try {
|
||||
$html = $this->fetchHtml($url);
|
||||
$ql = QueryList::html($html);
|
||||
|
||||
// 使用 Tailwind CSS 类名选择器(懂车帝使用 Tailwind)
|
||||
$nodes = $ql->find('div.tw-grid div.tw-relative div.tw-items-center>a');
|
||||
$texts = $nodes->texts()->all();
|
||||
$links = $nodes->attrs('href')->all();
|
||||
|
||||
// 提取价格信息(可选)
|
||||
$priceNodes = $ql->find('div.tw-grid div.tw-text-color-gray-800');
|
||||
$prices = $priceNodes->texts()->all();
|
||||
|
||||
foreach ($texts as $index => $text) {
|
||||
$modelName = trim($text);
|
||||
if (empty($modelName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$link = $links[$index] ?? '';
|
||||
if (!empty($link) && strpos($link, 'http') !== 0) {
|
||||
$link = 'https://www.dongchedi.com' . $link;
|
||||
}
|
||||
|
||||
$priceStr = $prices[$index] ?? '';
|
||||
$msrp = 0;
|
||||
if (!empty($priceStr)) {
|
||||
$msrp = (int)(str_replace("万", "", $priceStr) * 10000);
|
||||
}
|
||||
|
||||
$models[] = [
|
||||
'id' => $index,
|
||||
'name' => $modelName,
|
||||
'url' => $link,
|
||||
'msrp' => $msrp,
|
||||
];
|
||||
}
|
||||
|
||||
return $models;
|
||||
} catch (\Exception $e) {
|
||||
// 车型解析失败,记录日志并返回空数组
|
||||
error_log('获取车型列表失败: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从懂车帝URL中提取车系ID
|
||||
*
|
||||
* @param string $url 懂车帝URL
|
||||
* @return string|null 车系ID
|
||||
*/
|
||||
private function extractSeriesId(string $url): ?string
|
||||
{
|
||||
// 匹配 /auto/series/x123/ 格式
|
||||
if (preg_match('/\/auto\/series\/(\d+)/', $url, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// 匹配 /auto/series/x123/params/ 格式
|
||||
if (preg_match('/\/auto\/series\/(\d+)/', $url, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,34 @@ 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('/crawl/keyword', [app\controller\UsedCarController::class, 'crawlKeyword']);
|
||||
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::group('/api/v1/upload', function () {
|
||||
Route::post('/file', [app\controller\UploadController::class, 'upload']);
|
||||
Route::post('/image', [app\controller\UploadController::class, 'uploadImage']);
|
||||
Route::post('/usedcar', [app\controller\UploadController::class, 'uploadUsedCarImage']);
|
||||
Route::post('/batch', [app\controller\UploadController::class, 'batchUploadImages']);
|
||||
Route::post('/delete', [app\controller\UploadController::class, 'deleteFile']);
|
||||
Route::get('/info', [app\controller\UploadController::class, 'getFileInfo']);
|
||||
Route::get('/config', [app\controller\UploadController::class, 'getPartitionConfig']);
|
||||
})->middleware($apiMiddleware);
|
||||
|
||||
// 默认路由
|
||||
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生成日志表';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 上传文件记录表
|
||||
CREATE TABLE IF NOT EXISTS `uploaded_files` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`file_name` varchar(255) NOT NULL COMMENT '存储文件名',
|
||||
`file_path` varchar(500) NOT NULL COMMENT '文件存储路径',
|
||||
`file_size` bigint(20) NOT NULL DEFAULT '0' COMMENT '文件大小(字节)',
|
||||
`file_type` varchar(100) NOT NULL COMMENT '文件MIME类型',
|
||||
`category` varchar(50) DEFAULT 'general' COMMENT '文件分类(general/avatar/document等)',
|
||||
`original_name` varchar(255) DEFAULT NULL COMMENT '原始文件名',
|
||||
`extension` varchar(20) DEFAULT NULL COMMENT '文件扩展名',
|
||||
`partition` varchar(50) DEFAULT 'other' COMMENT '上传分区(image/document/audio等)',
|
||||
`upload_ip` varchar(45) DEFAULT NULL COMMENT '上传IP地址',
|
||||
`user_agent` text COMMENT '用户代理',
|
||||
`user_id` int(11) DEFAULT NULL COMMENT '上传用户ID',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_partition` (`partition`),
|
||||
KEY `idx_category` (`category`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_file_path` (`file_path`(255))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传文件记录表';
|
||||
@@ -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>
|
||||
@@ -0,0 +1,190 @@
|
||||
.carCardWrapper {
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carCardWrapper:active {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
padding: 8px 12px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.cardCode {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editBtn {
|
||||
padding: 4px 8px;
|
||||
font-size: 16px;
|
||||
color: #3F435B;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.carCard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 100px;
|
||||
height: 75px;
|
||||
border-radius: 10px;
|
||||
background: #eef2f7;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumbImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row1 {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ff6b35;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.row2,
|
||||
.row3 {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.k {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.v {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.actionsLeft {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.actionsRight {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition: all 0.2s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.actionBtn:active {
|
||||
transform: scale(0.92);
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.statusTag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusNormal {
|
||||
background: rgba(82, 196, 26, 0.1);
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.statusArchived {
|
||||
background: rgba(140, 140, 140, 0.1);
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.statusSold {
|
||||
background: rgba(250, 173, 20, 0.1);
|
||||
color: #faad14;
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Image, Toast, Dialog } from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import styles from './CarCard.module.css'
|
||||
import type { PriceFormData } from '@/components/PriceModal'
|
||||
|
||||
export interface UsedCarItem {
|
||||
id: number
|
||||
code: string
|
||||
product_name: string
|
||||
source_place?: string
|
||||
body_color?: string
|
||||
plate_time?: string
|
||||
mileage?: number
|
||||
images?: string
|
||||
images_pull?: Array<{ url: string }>
|
||||
status: number
|
||||
created_at: string
|
||||
pdf_src?: string
|
||||
price?: number
|
||||
}
|
||||
|
||||
interface CarCardProps {
|
||||
item: UsedCarItem
|
||||
isMy?: boolean
|
||||
index?: number
|
||||
onRefresh?: () => void
|
||||
onRequestPrice?: (data: { item: UsedCarItem; index: number }) => void
|
||||
}
|
||||
|
||||
const CarCard: React.FC<CarCardProps> = ({ item, isMy = false, index = -1, onRefresh, onRequestPrice }) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 格式化上牌时间
|
||||
const formatPlateTime = (dateStr?: string) => {
|
||||
if (!dateStr) return '-'
|
||||
return String(dateStr).slice(0, 7)
|
||||
}
|
||||
|
||||
// 获取图片
|
||||
const getImages = (item: UsedCarItem): string[] => {
|
||||
if (item.images_pull && item.images_pull.length > 0) {
|
||||
return item.images_pull.map(img => img.url)
|
||||
}
|
||||
if (item.images) {
|
||||
try {
|
||||
const images = JSON.parse(item.images)
|
||||
return Array.isArray(images) ? images : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 点击卡片跳转详情
|
||||
const handleCardClick = () => {
|
||||
if (item?.id) {
|
||||
navigate(`/used-car/detail/${item.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/used-car/edit/${item.id}`)
|
||||
}
|
||||
|
||||
// H5 下载:用 a 标签触发
|
||||
const downloadByAnchor = (url: string, filename?: string) => {
|
||||
try {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
if (filename) {
|
||||
a.download = filename
|
||||
}
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch {
|
||||
// 兜底:直接打开
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// 下载PDF - 如果存在 URL 直接下载,否则请求价格
|
||||
const handleDownloadPdf = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// 如果有 PDF URL,直接下载
|
||||
if (item.pdf_src && item.pdf_src.trim() !== '') {
|
||||
downloadByAnchor(item.pdf_src, `${item.product_name}-${item.code}.pdf`)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有 PDF URL,触发价格输入弹框
|
||||
if (onRequestPrice) {
|
||||
onRequestPrice({ item, index })
|
||||
}
|
||||
}
|
||||
|
||||
// 生成并下载 PDF(由价格弹框确认后调用)
|
||||
const generateAndDownloadPdf = useCallback(async (priceData: PriceFormData) => {
|
||||
try {
|
||||
Toast.show({ content: '正在生成 PDF...', icon: 'loading' })
|
||||
|
||||
// 调用生成 PDF 接口
|
||||
const res = await usedCarApi.generatePdf({
|
||||
id: item.id,
|
||||
price: Number(priceData.price),
|
||||
currency: priceData.currency,
|
||||
lang: priceData.lang,
|
||||
trade_term: priceData.trade_term
|
||||
}) as { pdf_url?: string; file_name?: string }
|
||||
|
||||
// 生成成功,下载 PDF
|
||||
if (res.pdf_url) {
|
||||
downloadByAnchor(res.pdf_url, res.file_name || `${item.product_name}-${item.code}.pdf`)
|
||||
Toast.show({ content: t('usedCar.pdfDownloadSuccess') || 'PDF 下载成功', icon: 'success' })
|
||||
// 刷新列表
|
||||
onRefresh?.()
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : (t('usedCar.pdfDownloadFailed') || 'PDF 生成失败')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
}
|
||||
}, [item.id, item.product_name, item.code, t, onRefresh])
|
||||
|
||||
// 导出方法供外部调用
|
||||
useEffect(() => {
|
||||
if (window) {
|
||||
(window as any).carCardDownloadPdf = generateAndDownloadPdf
|
||||
return () => { delete (window as any).carCardDownloadPdf }
|
||||
}
|
||||
}, [generateAndDownloadPdf])
|
||||
|
||||
// 等待飞书 tt 对象准备就绪
|
||||
const waitForFeishuSDK = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 如果已经存在,直接返回
|
||||
if (window.tt && (window as any).h5sdk) {
|
||||
;(window as any).h5sdk.ready(() => {
|
||||
resolve()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 轮询等待 tt 对象
|
||||
let attempts = 0
|
||||
const maxAttempts = 50
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++
|
||||
if (window.tt && (window as any).h5sdk) {
|
||||
clearInterval(checkInterval)
|
||||
;(window as any).h5sdk.ready(() => {
|
||||
resolve()
|
||||
})
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(checkInterval)
|
||||
reject(new Error('TT_OBJECT_TIMEOUT'))
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
// 分享
|
||||
const handleShare = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const title = item.product_name || '车辆详情'
|
||||
const link = `${window.location.origin}/#/used-car/detail/${item.id}`
|
||||
const images = getImages(item)
|
||||
|
||||
// 检测是否在飞书环境
|
||||
const isFeishuEnv = /feishu|lark/i.test(navigator.userAgent)
|
||||
|
||||
if (isFeishuEnv) {
|
||||
try {
|
||||
// 等待飞书 SDK 准备就绪
|
||||
await waitForFeishuSDK()
|
||||
|
||||
// 调用飞书分享
|
||||
;(window as any).tt.shareWebContent({
|
||||
title,
|
||||
content: title,
|
||||
url: link,
|
||||
imageUrl: images[0] || '',
|
||||
success: () => {
|
||||
Toast.show({ content: '已唤起飞书分享', icon: 'success' })
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('飞书分享失败:', err)
|
||||
// 飞书分享失败,尝试复制链接
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('飞书 SDK 加载失败:', error)
|
||||
// SDK 加载失败,尝试 Web Share API 或复制链接
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, text: title, url: link }).catch(() => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
})
|
||||
})
|
||||
} else {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 非飞书环境,使用 Web Share API
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, text: title, url: link }).catch(() => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 最后的兜底:复制链接
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
|
||||
// 标记已售出
|
||||
const handleMarkSold = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
Dialog.confirm({
|
||||
content: t('usedCar.confirmMarkSold'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await usedCarApi.updateStatus(item.id, 2)
|
||||
Toast.show({ content: '已标记为售出', icon: 'success' })
|
||||
onRefresh?.()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '操作失败'
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const images = getImages(item)
|
||||
|
||||
return (
|
||||
<div className={styles.carCardWrapper}>
|
||||
{/* 头部 */}
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.cardCode}>
|
||||
{t('usedCar.code')}: {item.code || '~'}
|
||||
</span>
|
||||
{isMy && (
|
||||
<button className={styles.editBtn} onClick={handleEdit}>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 卡片主体 */}
|
||||
<div className={styles.carCard} onClick={handleCardClick}>
|
||||
{/* 缩略图 */}
|
||||
{images.length > 0 && (
|
||||
<div className={styles.thumb}>
|
||||
<Image src={images[0]} fit="cover" className={styles.thumbImage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 信息 */}
|
||||
<div className={styles.info}>
|
||||
<div className={styles.row1}>
|
||||
<span className={styles.name}>{item.product_name}</span>
|
||||
{item.price && <span className={styles.price}>¥{item.price}</span>}
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<div className={styles.kv}>
|
||||
<span className={styles.k}>{t('usedCar.sourcePlace')}</span>
|
||||
<span className={styles.v}>{item.source_place || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.kv}>
|
||||
<span className={styles.k}>{t('usedCar.bodyColor')}</span>
|
||||
<span className={styles.v}>{item.body_color || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.row3}>
|
||||
<div className={styles.kv}>
|
||||
<span className={styles.k}>{t('usedCar.plateTime')}</span>
|
||||
<span className={styles.v}>{formatPlateTime(item.plate_time)}</span>
|
||||
</div>
|
||||
<div className={styles.kv}>
|
||||
<span className={styles.k}>{t('usedCar.mileage')}</span>
|
||||
<span className={styles.v}>{item.mileage ? `${item.mileage} Km` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className={styles.cardActions}>
|
||||
<div className={styles.actionsLeft}>
|
||||
<span>{t('usedCar.createdAt')}: {item.created_at}</span>
|
||||
</div>
|
||||
<div className={styles.actionsRight}>
|
||||
<button className={styles.actionBtn} onClick={handleDownloadPdf}>
|
||||
📥
|
||||
</button>
|
||||
<button className={styles.actionBtn} onClick={handleShare}>
|
||||
🔗
|
||||
</button>
|
||||
{isMy && item.status === 1 && (
|
||||
<button className={styles.actionBtn} onClick={handleMarkSold}>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CarCard
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './CarCard'
|
||||
export type { UsedCarItem } from './CarCard'
|
||||
@@ -0,0 +1,25 @@
|
||||
/* 价格弹框容器 - 覆盖 antd-mobile 默认样式 */
|
||||
.modalWrapper :global(.adm-center-popup-wrap) {
|
||||
width: 85%;
|
||||
max-width: 420px;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
/* 弹框内容区域 */
|
||||
.modalWrapper :global(.adm-modal-body) {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modalWrapper :global(.adm-modal-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 确保弹框在移动端有合适的宽度 */
|
||||
@media (max-width: 480px) {
|
||||
.modalWrapper :global(.adm-center-popup-wrap) {
|
||||
width: 90%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Modal, Input, Toast } from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
|
||||
interface PriceModalProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (data: PriceFormData) => void
|
||||
item?: any
|
||||
}
|
||||
|
||||
export interface PriceFormData {
|
||||
trade_term: string
|
||||
price: string
|
||||
currency: string
|
||||
lang: string
|
||||
}
|
||||
|
||||
// 贸易条款选项
|
||||
const TRADE_TERM_OPTIONS = [
|
||||
{ value: 'FOB', label: 'FOB' },
|
||||
{ value: 'CIF', label: 'CIF' },
|
||||
{ value: 'EXW', label: 'EXW' },
|
||||
{ value: 'DDP', label: 'DDP' },
|
||||
{ value: 'CIP', label: 'CIP' },
|
||||
{ value: 'FCA', label: 'FCA' },
|
||||
{ value: 'CFR', label: 'CFR' }
|
||||
]
|
||||
|
||||
// 币种选项
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ value: 'USD', label: '美元' },
|
||||
{ value: 'CNY', label: '人民币' },
|
||||
{ value: 'RUB', label: '卢布' },
|
||||
{ value: 'AED', label: '迪拉姆' },
|
||||
{ value: 'EUR', label: '欧元' }
|
||||
]
|
||||
|
||||
// 语言选项
|
||||
const LANG_OPTIONS = [
|
||||
{ value: 'en', label: '英语' },
|
||||
{ value: 'ru', label: '俄语' },
|
||||
{ value: 'es', label: '西班牙语' },
|
||||
{ value: 'ar', label: '阿拉伯语' }
|
||||
]
|
||||
|
||||
const PriceModal: React.FC<PriceModalProps> = ({ visible, onClose, onConfirm, item }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [formData, setFormData] = useState<PriceFormData>({
|
||||
trade_term: 'FOB',
|
||||
price: '',
|
||||
currency: 'USD',
|
||||
lang: 'en'
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 注入全局样式 - 只在组件挂载时执行一次
|
||||
useEffect(() => {
|
||||
const styleId = 'price-modal-override-style'
|
||||
let styleEl = document.getElementById(styleId) as HTMLStyleElement
|
||||
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style')
|
||||
styleEl.id = styleId
|
||||
styleEl.innerHTML = `
|
||||
.adm-center-popup-wrap {
|
||||
width: 85% !important;
|
||||
max-width: 420px !important;
|
||||
min-width: unset !important;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.adm-center-popup-wrap {
|
||||
width: 90% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
`
|
||||
document.head.appendChild(styleEl)
|
||||
}
|
||||
|
||||
return () => {
|
||||
styleEl?.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 重置表单
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setFormData({
|
||||
trade_term: 'FOB',
|
||||
price: '',
|
||||
currency: 'USD',
|
||||
lang: 'en'
|
||||
})
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 价格输入处理
|
||||
const handlePriceInput = (value: string) => {
|
||||
// 只允许数字和小数点
|
||||
const cleanValue = value.replace(/[^\d.]/g, '')
|
||||
// 只保留第一个小数点
|
||||
const parts = cleanValue.split('.')
|
||||
if (parts.length > 2) {
|
||||
const finalValue = parts[0] + '.' + parts.slice(1).join('')
|
||||
setFormData({ ...formData, price: finalValue })
|
||||
} else {
|
||||
setFormData({ ...formData, price: cleanValue })
|
||||
}
|
||||
}
|
||||
|
||||
// 确认生成
|
||||
const handleConfirm = async () => {
|
||||
if (!formData.trade_term) {
|
||||
return Toast.show({ content: t('usedCar.selectTradeTerm') || '请选择贸易条款', icon: 'fail' })
|
||||
}
|
||||
if (!formData.price) {
|
||||
return Toast.show({ content: t('usedCar.enterPrice') || '请输入价格', icon: 'fail' })
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await onConfirm(formData)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('生成 PDF 失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
content={
|
||||
<div style={{ padding: '24px 20px 20px' }}>
|
||||
{/* 标题 */}
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, marginBottom: '20px', textAlign: 'center' }}>
|
||||
{t('usedCar.setPrice') || '设置价格'}
|
||||
</div>
|
||||
|
||||
{/* 贸易条款 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
{t('usedCar.tradeTerm') || '贸易条款'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.trade_term}
|
||||
onChange={(e) => setFormData({ ...formData, trade_term: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{TRADE_TERM_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 价格 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
{t('usedCar.price') || '价格'}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice') || '请输入价格'}
|
||||
value={formData.price}
|
||||
onChange={handlePriceInput}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 币种 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
{t('usedCar.currency') || '币种'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.currency}
|
||||
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{CURRENCY_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语言 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{ display: 'block', fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
{t('usedCar.language') || '语言'}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
{LANG_OPTIONS.map(opt => (
|
||||
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="lang"
|
||||
value={opt.value}
|
||||
checked={formData.lang === opt.value}
|
||||
onChange={(e) => setFormData({ ...formData, lang: e.target.value })}
|
||||
/>
|
||||
<span style={{ fontSize: '14px' }}>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#f5f5f5',
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{t('common.cancel') || '取消'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: loading ? '#ccc' : '#3F435B',
|
||||
color: '#fff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
cursor: loading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{loading ? (t('common.loading') || '生成中...') : (t('common.confirm') || '确认')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
closeOnMaskClick={false}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PriceModal
|
||||
@@ -0,0 +1,223 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 新增的翻译键
|
||||
const newTranslations = {
|
||||
zh: {
|
||||
pageTitle: '二手车录入',
|
||||
bind: '绑定',
|
||||
bindProduct: '绑定产品',
|
||||
bindDialog: '选择要绑定的产品',
|
||||
bindSearchPlaceholder: '搜索产品名称',
|
||||
bindLoading: '加载中…',
|
||||
bindEmpty: '未找到匹配的产品',
|
||||
bindConfirm: '确认绑定',
|
||||
bindClear: '清除',
|
||||
bindBound: '已绑定:',
|
||||
bindSuccess: '绑定成功',
|
||||
bindCleared: '已清除绑定',
|
||||
enterProductName: '请输入车辆的品牌 系列查找车型',
|
||||
enterProductNameFirst: '请先输入产品名称',
|
||||
bindFetchFailed: '拉取产品失败,请稍后重试',
|
||||
bindExample: '例:比亚迪海狮07DM',
|
||||
enterPrice: '请输入车价',
|
||||
enterSourcePlace: '请输入车源地',
|
||||
selectPlateTime: '请选择时间',
|
||||
enterDisplacement: '请输入排量',
|
||||
enterPower: '请输入马力',
|
||||
enterMileage: '请输入里程',
|
||||
enterBodyColor: '请输入车身颜色',
|
||||
enterConditionDesc: '请输入车况介绍',
|
||||
uploadImages: '上传产品图',
|
||||
uploadAttachments: '上传附件',
|
||||
maxImages: '最多上传20张图片',
|
||||
maxAttachments: '最多上传20个附件',
|
||||
uploading: '上传中...',
|
||||
uploadFailed: '上传失败',
|
||||
removeImage: '删除',
|
||||
view: '查看',
|
||||
requiredField: '必填项',
|
||||
fillRequiredFields: '请填写所有必填项',
|
||||
confirmSubmit: '确认提交?'
|
||||
},
|
||||
en: {
|
||||
pageTitle: 'Add Used Car',
|
||||
bind: 'Bind',
|
||||
bindProduct: 'Bind Product',
|
||||
bindDialog: 'Select Product to Bind',
|
||||
bindSearchPlaceholder: 'Search product name',
|
||||
bindLoading: 'Loading…',
|
||||
bindEmpty: 'No matching products found',
|
||||
bindConfirm: 'Confirm Bind',
|
||||
bindClear: 'Clear',
|
||||
bindBound: 'Bound: ',
|
||||
bindSuccess: 'Bind successful',
|
||||
bindCleared: 'Bind cleared',
|
||||
enterProductName: 'Enter vehicle brand and series to find model',
|
||||
enterProductNameFirst: 'Please enter product name first',
|
||||
bindFetchFailed: 'Failed to fetch products, please try again later',
|
||||
bindExample: 'Ex: BYD Seal 07 DM',
|
||||
enterPrice: 'Enter price',
|
||||
enterSourcePlace: 'Enter source place',
|
||||
selectPlateTime: 'Select time',
|
||||
enterDisplacement: 'Enter displacement',
|
||||
enterPower: 'Enter power',
|
||||
enterMileage: 'Enter mileage',
|
||||
enterBodyColor: 'Enter body color',
|
||||
enterConditionDesc: 'Enter condition description',
|
||||
uploadImages: 'Upload Images',
|
||||
uploadAttachments: 'Upload Attachments',
|
||||
maxImages: 'Maximum 20 images',
|
||||
maxAttachments: 'Maximum 20 attachments',
|
||||
uploading: 'Uploading...',
|
||||
uploadFailed: 'Upload failed',
|
||||
removeImage: 'Remove',
|
||||
view: 'View',
|
||||
requiredField: 'Required',
|
||||
fillRequiredFields: 'Please fill all required fields',
|
||||
confirmSubmit: 'Confirm submit?'
|
||||
},
|
||||
ru: {
|
||||
pageTitle: 'Добавить подержанный автомобиль',
|
||||
bind: 'Привязать',
|
||||
bindProduct: 'Привязать продукт',
|
||||
bindDialog: 'Выберите продукт для привязки',
|
||||
bindSearchPlaceholder: 'Поиск по названию',
|
||||
bindLoading: 'Загрузка…',
|
||||
bindEmpty: 'Продукты не найдены',
|
||||
bindConfirm: 'Подтвердить',
|
||||
bindClear: 'Очистить',
|
||||
bindBound: 'Привязано: ',
|
||||
bindSuccess: 'Привязка успешна',
|
||||
bindCleared: 'Привязка очищена',
|
||||
enterProductName: 'Введите бренд и серию автомобиля',
|
||||
enterProductNameFirst: 'Сначала введите название продукта',
|
||||
bindFetchFailed: 'Не удалось получить продукты, попробуйте позже',
|
||||
bindExample: 'Пример: BYD Seal 07 DM',
|
||||
enterPrice: 'Введите цену',
|
||||
enterSourcePlace: 'Введите место происхождения',
|
||||
selectPlateTime: 'Выберите время',
|
||||
enterDisplacement: 'Введите объем двигателя',
|
||||
enterPower: 'Введите мощность',
|
||||
enterMileage: 'Введите пробег',
|
||||
enterBodyColor: 'Введите цвет кузова',
|
||||
enterConditionDesc: 'Введите описание состояния',
|
||||
uploadImages: 'Загрузить изображения',
|
||||
uploadAttachments: 'Загрузить вложения',
|
||||
maxImages: 'Максимум 20 изображений',
|
||||
maxAttachments: 'Максимум 20 вложений',
|
||||
uploading: 'Загрузка...',
|
||||
uploadFailed: 'Загрузка не удалась',
|
||||
removeImage: 'Удалить',
|
||||
view: 'Просмотр',
|
||||
requiredField: 'Обязательно',
|
||||
fillRequiredFields: 'Заполните все обязательные поля',
|
||||
confirmSubmit: 'Подтвердить отправку?'
|
||||
},
|
||||
es: {
|
||||
pageTitle: 'Añadir Coche Usado',
|
||||
bind: 'Vincular',
|
||||
bindProduct: 'Vincular Producto',
|
||||
bindDialog: 'Seleccionar Producto para Vincular',
|
||||
bindSearchPlaceholder: 'Buscar nombre del producto',
|
||||
bindLoading: 'Cargando…',
|
||||
bindEmpty: 'No se encontraron productos coincidentes',
|
||||
bindConfirm: 'Confirmar Vinculación',
|
||||
bindClear: 'Limpiar',
|
||||
bindBound: 'Vinculado: ',
|
||||
bindSuccess: 'Vinculación exitosa',
|
||||
bindCleared: 'Vinculación borrada',
|
||||
enterProductName: 'Ingrese la marca y serie del vehículo',
|
||||
enterProductNameFirst: 'Primero ingrese el nombre del producto',
|
||||
bindFetchFailed: 'Error al obtener productos, inténtelo más tarde',
|
||||
bindExample: 'Ej: BYD Seal 07 DM',
|
||||
enterPrice: 'Ingrese el precio',
|
||||
enterSourcePlace: 'Ingrese el lugar de origen',
|
||||
selectPlateTime: 'Seleccionar tiempo',
|
||||
enterDisplacement: 'Ingrese el desplazamiento',
|
||||
enterPower: 'Ingrese la potencia',
|
||||
enterMileage: 'Ingrese el kilometraje',
|
||||
enterBodyColor: 'Ingrese el color de la carrocería',
|
||||
enterConditionDesc: 'Ingrese la descripción del estado',
|
||||
uploadImages: 'Subir Imágenes',
|
||||
uploadAttachments: 'Subir Adjuntos',
|
||||
maxImages: 'Máximo 20 imágenes',
|
||||
maxAttachments: 'Máximo 20 adjuntos',
|
||||
uploading: 'Subiendo...',
|
||||
uploadFailed: 'Error al subir',
|
||||
removeImage: 'Eliminar',
|
||||
view: 'Ver',
|
||||
requiredField: 'Requerido',
|
||||
fillRequiredFields: 'Complete todos los campos requeridos',
|
||||
confirmSubmit: '¿Confirmar envío?'
|
||||
},
|
||||
fr: {
|
||||
pageTitle: 'Ajouter une Voiture d\'occasion',
|
||||
bind: 'Lier',
|
||||
bindProduct: 'Lier le Produit',
|
||||
bindDialog: 'Sélectionner un Produit à Lier',
|
||||
bindSearchPlaceholder: 'Rechercher nom du produit',
|
||||
bindLoading: 'Chargement…',
|
||||
bindEmpty: 'Aucun produit correspondant trouvé',
|
||||
bindConfirm: 'Confirmer la Liaison',
|
||||
bindClear: 'Effacer',
|
||||
bindBound: 'Lié : ',
|
||||
bindSuccess: 'Liaison réussie',
|
||||
bindCleared: 'Liaison effacée',
|
||||
enterProductName: 'Entrez la marque et la série du véhicule',
|
||||
enterProductNameFirst: 'Veuillez d\'abord entrer le nom du produit',
|
||||
bindFetchFailed: 'Échec de la récupération des produits, réessayez plus tard',
|
||||
bindExample: 'Ex: BYD Seal 07 DM',
|
||||
enterPrice: 'Entrez le prix',
|
||||
enterSourcePlace: 'Entrez le lieu d\'origine',
|
||||
selectPlateTime: 'Sélectionner l\'heure',
|
||||
enterDisplacement: 'Entrez la cylindrée',
|
||||
enterPower: 'Entrez la puissance',
|
||||
enterMileage: 'Entrez le kilométrage',
|
||||
enterBodyColor: 'Entrez la couleur de la carrosserie',
|
||||
enterConditionDesc: 'Entrez la description de l\'état',
|
||||
uploadImages: 'Télécharger des Images',
|
||||
uploadAttachments: 'Télécharger des Pièces Jointes',
|
||||
maxImages: 'Maximum 20 images',
|
||||
maxAttachments: 'Maximum 20 pièces jointes',
|
||||
uploading: 'Téléchargement...',
|
||||
uploadFailed: 'Échec du téléchargement',
|
||||
removeImage: 'Supprimer',
|
||||
view: 'Voir',
|
||||
requiredField: 'Obligatoire',
|
||||
fillRequiredFields: 'Veuillez remplir tous les champs obligatoires',
|
||||
confirmSubmit: 'Confirmer l\'envoi ?'
|
||||
}
|
||||
};
|
||||
|
||||
// 语言列表
|
||||
const languages = ['zh', 'en', 'ru', 'es', 'fr'];
|
||||
const localesDir = path.join(__dirname);
|
||||
|
||||
// 更新每个语言文件
|
||||
languages.forEach(lang => {
|
||||
const filePath = path.join(localesDir, `${lang}.json`);
|
||||
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
// 添加 usedCar 的新键(如果不存在)
|
||||
if (!content.usedCar) {
|
||||
content.usedCar = {};
|
||||
}
|
||||
|
||||
const newKeys = newTranslations[lang];
|
||||
Object.keys(newKeys).forEach(key => {
|
||||
if (!(key in content.usedCar)) {
|
||||
content.usedCar[key] = newKeys[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 添加 common.clear (如果不存在)
|
||||
if (!content.common.clear) {
|
||||
content.common.clear = newTranslations[lang].clear || '清除';
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(content, null, 2), 'utf8');
|
||||
console.log(`Updated ${lang}.json`);
|
||||
});
|
||||
|
||||
console.log('All translation files updated successfully!');
|
||||
@@ -29,7 +29,8 @@
|
||||
"status": "Status",
|
||||
"remark": "Remark",
|
||||
"createTime": "Create Time",
|
||||
"updateTime": "Update Time"
|
||||
"updateTime": "Update Time",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Welcome Back",
|
||||
@@ -186,5 +187,104 @@
|
||||
"lastMonth": "Last Month",
|
||||
"thisYear": "This Year",
|
||||
"lastYear": "Last Year"
|
||||
},
|
||||
"usedCar": {
|
||||
"title": "Used Car",
|
||||
"code": "Code",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search vehicle name",
|
||||
"status": "Status",
|
||||
"statusNormal": "Normal",
|
||||
"statusArchived": "Archived",
|
||||
"statusSold": "Sold",
|
||||
"markSold": "Mark as Sold",
|
||||
"downloadPdf": "Download PDF",
|
||||
"share": "Share",
|
||||
"empty": "No used cars",
|
||||
"loadFailed": "Failed to load",
|
||||
"pdfDownloadFailed": "Failed to download PDF",
|
||||
"sourcePlace": "Source Place",
|
||||
"bodyColor": "Body Color",
|
||||
"plateTime": "Plate Time",
|
||||
"mileage": "Mileage",
|
||||
"createdAt": "Created At",
|
||||
"productName": "Product Name",
|
||||
"vin": "VIN",
|
||||
"price": "Price",
|
||||
"displacement": "Displacement",
|
||||
"power": "Power",
|
||||
"conditionDesc": "Condition Description",
|
||||
"productImages": "Product Images",
|
||||
"attachments": "Attachments",
|
||||
"create": "Add Used Car",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"detail": "Detail",
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmDelete": "Confirm to delete this used car?",
|
||||
"confirmMarkSold": "Confirm to mark as sold?",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"updateSuccess": "Updated successfully",
|
||||
"createSuccess": "Created successfully",
|
||||
"setPrice": "Set Price",
|
||||
"tradeTerm": "Trade Term",
|
||||
"selectTradeTerm": "Please select trade term",
|
||||
"enterPrice": "Enter price",
|
||||
"currency": "Currency",
|
||||
"language": "Language",
|
||||
"pdfDownloadSuccess": "PDF downloaded successfully",
|
||||
"linkCopied": "Link copied",
|
||||
"notFound": "Vehicle not found",
|
||||
"pageTitle": "Add Used Car",
|
||||
"bind": "Bind",
|
||||
"bindProduct": "Bind Product",
|
||||
"bindDialog": "Select Product to Bind",
|
||||
"bindSearchPlaceholder": "Search product name",
|
||||
"bindLoading": "Loading…",
|
||||
"bindEmpty": "No matching products found",
|
||||
"bindConfirm": "Confirm Bind",
|
||||
"bindClear": "Clear",
|
||||
"bindBound": "Bound: ",
|
||||
"bindSuccess": "Bind successful",
|
||||
"bindCleared": "Bind cleared",
|
||||
"enterProductName": "Enter vehicle brand and series to find model",
|
||||
"enterProductNameFirst": "Please enter product name first",
|
||||
"bindFetchFailed": "Failed to fetch products, please try again later",
|
||||
"bindExample": "Ex: BYD Seal 07 DM",
|
||||
"enterSourcePlace": "Enter source place",
|
||||
"selectPlateTime": "Select time",
|
||||
"enterDisplacement": "Enter displacement",
|
||||
"enterPower": "Enter power",
|
||||
"enterMileage": "Enter mileage",
|
||||
"enterBodyColor": "Enter body color",
|
||||
"enterConditionDesc": "Enter condition description",
|
||||
"uploadImages": "Upload Images",
|
||||
"uploadAttachments": "Upload Attachments",
|
||||
"maxImages": "Maximum 20 images",
|
||||
"maxAttachments": "Maximum 20 attachments",
|
||||
"uploading": "Uploading...",
|
||||
"uploadFailed": "Upload failed",
|
||||
"removeImage": "Remove",
|
||||
"view": "View",
|
||||
"requiredField": "Required",
|
||||
"fillRequiredFields": "Please fill all required fields",
|
||||
"confirmSubmit": "Confirm submit?",
|
||||
"myPublish": "My Publish",
|
||||
"myGarage": "My Garage",
|
||||
"quickEdit": "Quick Edit",
|
||||
"emptyMyPublish": "No published vehicles yet",
|
||||
"myPage": "Mine",
|
||||
"personalInfo": "Personal Info",
|
||||
"bindDialog": "Select Car Model",
|
||||
"brand": "Brand",
|
||||
"series": "Series",
|
||||
"bindSearchPlaceholder": "Search car model",
|
||||
"bindSearching": "Searching...",
|
||||
"bindSearchFirst": "Please enter product name to search",
|
||||
"bindNoModels": "No car models found",
|
||||
"bindNoResults": "No matching results",
|
||||
"bindConfirm": "Confirm Bind"
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
"status": "Estado",
|
||||
"remark": "Observación",
|
||||
"createTime": "Fecha de creación",
|
||||
"updateTime": "Fecha de actualización"
|
||||
"updateTime": "Fecha de actualización",
|
||||
"clear": "Limpiar"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Bienvenido de nuevo",
|
||||
@@ -161,5 +162,99 @@
|
||||
"lastMonth": "Mes Pasado",
|
||||
"thisYear": "Este Año",
|
||||
"lastYear": "Año Pasado"
|
||||
},
|
||||
"usedCar": {
|
||||
"title": "Coche de Segunda Mano",
|
||||
"code": "Código",
|
||||
"search": "Buscar",
|
||||
"searchPlaceholder": "Buscar vehículo",
|
||||
"status": "Estado",
|
||||
"statusNormal": "Normal",
|
||||
"statusArchived": "Archivado",
|
||||
"statusSold": "Vendido",
|
||||
"markSold": "Marcar como vendido",
|
||||
"downloadPdf": "Descargar PDF",
|
||||
"share": "Compartir",
|
||||
"empty": "Sin coches",
|
||||
"loadFailed": "Error al cargar",
|
||||
"pdfDownloadFailed": "Error al descargar PDF",
|
||||
"sourcePlace": "Lugar de origen",
|
||||
"bodyColor": "Color de carrocería",
|
||||
"plateTime": "Fecha de matriculación",
|
||||
"mileage": "Kilometraje",
|
||||
"createdAt": "Creado en",
|
||||
"productName": "Nombre del producto",
|
||||
"vin": "VIN",
|
||||
"price": "Precio",
|
||||
"displacement": "Cilindrada",
|
||||
"power": "Potencia",
|
||||
"conditionDesc": "Descripción del estado",
|
||||
"productImages": "Imágenes del producto",
|
||||
"attachments": "Adjuntos",
|
||||
"create": "Añadir coche",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"detail": "Detalle",
|
||||
"submit": "Enviar",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"confirmDelete": "¿Confirmar eliminar?",
|
||||
"confirmMarkSold": "¿Marcar como vendido?",
|
||||
"deleteSuccess": "Eliminado",
|
||||
"updateSuccess": "Actualizado",
|
||||
"createSuccess": "Creado",
|
||||
"setPrice": "Establecer precio",
|
||||
"tradeTerm": "Término comercial",
|
||||
"selectTradeTerm": "Seleccione término comercial",
|
||||
"enterPrice": "Ingrese el precio",
|
||||
"currency": "Moneda",
|
||||
"language": "Idioma",
|
||||
"pdfDownloadSuccess": "PDF descargado con éxito",
|
||||
"linkCopied": "Enlace copiado",
|
||||
"notFound": "Vehículo no encontrado",
|
||||
"pageTitle": "Añadir Coche Usado",
|
||||
"bind": "Vincular",
|
||||
"bindProduct": "Vincular Producto",
|
||||
"bindDialog": "Seleccionar Modelo",
|
||||
"brand": "Marca",
|
||||
"series": "Serie",
|
||||
"bindSearchPlaceholder": "Buscar modelo",
|
||||
"bindSearching": "Buscando...",
|
||||
"bindSearchFirst": "Primero ingrese el nombre del producto",
|
||||
"bindNoModels": "No se encontraron modelos",
|
||||
"bindNoResults": "Sin resultados",
|
||||
"bindConfirm": "Confirmar Vinculación",
|
||||
"bindClear": "Limpiar",
|
||||
"bindBound": "Vinculado: ",
|
||||
"bindSuccess": "Vinculación exitosa",
|
||||
"bindCleared": "Vinculación borrada",
|
||||
"enterProductName": "Ingrese la marca y serie del vehículo",
|
||||
"enterProductNameFirst": "Primero ingrese el nombre del producto",
|
||||
"bindFetchFailed": "Error al obtener productos, inténtelo más tarde",
|
||||
"bindExample": "Ej: BYD Seal 07 DM",
|
||||
"enterSourcePlace": "Ingrese el lugar de origen",
|
||||
"selectPlateTime": "Seleccionar tiempo",
|
||||
"enterDisplacement": "Ingrese el desplazamiento",
|
||||
"enterPower": "Ingrese la potencia",
|
||||
"enterMileage": "Ingrese el kilometraje",
|
||||
"enterBodyColor": "Ingrese el color de la carrocería",
|
||||
"enterConditionDesc": "Ingrese la descripción del estado",
|
||||
"uploadImages": "Subir Imágenes",
|
||||
"uploadAttachments": "Subir Adjuntos",
|
||||
"maxImages": "Máximo 20 imágenes",
|
||||
"maxAttachments": "Máximo 20 adjuntos",
|
||||
"uploading": "Subiendo...",
|
||||
"uploadFailed": "Error al subir",
|
||||
"removeImage": "Eliminar",
|
||||
"view": "Ver",
|
||||
"requiredField": "Requerido",
|
||||
"fillRequiredFields": "Complete todos los campos requeridos",
|
||||
"confirmSubmit": "¿Confirmar envío?",
|
||||
"myPublish": "Mis Publicaciones",
|
||||
"myGarage": "Mi Garaje",
|
||||
"quickEdit": "Edición Rápida",
|
||||
"emptyMyPublish": "Aún no has publicado vehículos",
|
||||
"myPage": "Mío",
|
||||
"personalInfo": "Información personal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"status": "Statut",
|
||||
"remark": "Remarque",
|
||||
"createTime": "Date de création",
|
||||
"updateTime": "Date de mise à jour"
|
||||
"updateTime": "Date de mise à jour",
|
||||
"clear": "Effacer"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Bienvenue",
|
||||
@@ -161,5 +162,99 @@
|
||||
"lastMonth": "Mois Dernier",
|
||||
"thisYear": "Cette Année",
|
||||
"lastYear": "Année Dernière"
|
||||
},
|
||||
"usedCar": {
|
||||
"title": "Voiture d'Occasion",
|
||||
"code": "Code",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher véhicule",
|
||||
"status": "Statut",
|
||||
"statusNormal": "Normal",
|
||||
"statusArchived": "Archivé",
|
||||
"statusSold": "Vendu",
|
||||
"markSold": "Marquer comme vendu",
|
||||
"downloadPdf": "Télécharger PDF",
|
||||
"share": "Partager",
|
||||
"empty": "Aucune voiture",
|
||||
"loadFailed": "Échec du chargement",
|
||||
"pdfDownloadFailed": "Échec du téléchargement PDF",
|
||||
"sourcePlace": "Lieu d'origine",
|
||||
"bodyColor": "Couleur de la carrosserie",
|
||||
"plateTime": "Date d'immatriculation",
|
||||
"mileage": "Kilométrage",
|
||||
"createdAt": "Créé le",
|
||||
"productName": "Nom du produit",
|
||||
"vin": "VIN",
|
||||
"price": "Prix",
|
||||
"displacement": "Cylindrée",
|
||||
"power": "Puissance",
|
||||
"conditionDesc": "Description de l'état",
|
||||
"productImages": "Images du produit",
|
||||
"attachments": "Pièces jointes",
|
||||
"create": "Ajouter voiture",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"detail": "Détail",
|
||||
"submit": "Soumettre",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"confirmDelete": "Confirmer la suppression?",
|
||||
"confirmMarkSold": "Marquer comme vendu?",
|
||||
"deleteSuccess": "Supprimé",
|
||||
"updateSuccess": "Mis à jour",
|
||||
"createSuccess": "Créé",
|
||||
"setPrice": "Définir le prix",
|
||||
"tradeTerm": "Terme commercial",
|
||||
"selectTradeTerm": "Sélectionnez le terme commercial",
|
||||
"enterPrice": "Entrez le prix",
|
||||
"currency": "Devise",
|
||||
"language": "Langue",
|
||||
"pdfDownloadSuccess": "PDF téléchargé avec succès",
|
||||
"linkCopied": "Lien copié",
|
||||
"notFound": "Véhicule introuvable",
|
||||
"pageTitle": "Ajouter une Voiture d'occasion",
|
||||
"bind": "Lier",
|
||||
"bindProduct": "Lier le Produit",
|
||||
"bindDialog": "Sélectionner le Modèle",
|
||||
"brand": "Marque",
|
||||
"series": "Série",
|
||||
"bindSearchPlaceholder": "Rechercher modèle",
|
||||
"bindSearching": "Recherche...",
|
||||
"bindSearchFirst": "Veuillez d'abord entrer le nom du produit",
|
||||
"bindNoModels": "Aucun modèle trouvé",
|
||||
"bindNoResults": "Aucun résultat",
|
||||
"bindConfirm": "Confirmer la Liaison",
|
||||
"bindClear": "Effacer",
|
||||
"bindBound": "Lié : ",
|
||||
"bindSuccess": "Liaison réussie",
|
||||
"bindCleared": "Liaison effacée",
|
||||
"enterProductName": "Entrez la marque et la série du véhicule",
|
||||
"enterProductNameFirst": "Veuillez d'abord entrer le nom du produit",
|
||||
"bindFetchFailed": "Échec de la récupération des produits, réessayez plus tard",
|
||||
"bindExample": "Ex: BYD Seal 07 DM",
|
||||
"enterSourcePlace": "Entrez le lieu d'origine",
|
||||
"selectPlateTime": "Sélectionner l'heure",
|
||||
"enterDisplacement": "Entrez la cylindrée",
|
||||
"enterPower": "Entrez la puissance",
|
||||
"enterMileage": "Entrez le kilométrage",
|
||||
"enterBodyColor": "Entrez la couleur de la carrosserie",
|
||||
"enterConditionDesc": "Entrez la description de l'état",
|
||||
"uploadImages": "Télécharger des Images",
|
||||
"uploadAttachments": "Télécharger des Pièces Jointes",
|
||||
"maxImages": "Maximum 20 images",
|
||||
"maxAttachments": "Maximum 20 pièces jointes",
|
||||
"uploading": "Téléchargement...",
|
||||
"uploadFailed": "Échec du téléchargement",
|
||||
"removeImage": "Supprimer",
|
||||
"view": "Voir",
|
||||
"requiredField": "Obligatoire",
|
||||
"fillRequiredFields": "Veuillez remplir tous les champs obligatoires",
|
||||
"confirmSubmit": "Confirmer l'envoi ?",
|
||||
"myPublish": "Mes Publications",
|
||||
"myGarage": "Mon Garage",
|
||||
"quickEdit": "Modification Rapide",
|
||||
"emptyMyPublish": "Vous n'avez pas encore publié de véhicules",
|
||||
"myPage": "Mon",
|
||||
"personalInfo": "Informations personnelles"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"status": "Статус",
|
||||
"remark": "Примечание",
|
||||
"createTime": "Время создания",
|
||||
"updateTime": "Время обновления"
|
||||
"updateTime": "Время обновления",
|
||||
"clear": "Очистить"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Добро пожаловать",
|
||||
@@ -161,5 +162,99 @@
|
||||
"lastMonth": "В прошлом месяце",
|
||||
"thisYear": "В этом году",
|
||||
"lastYear": "В прошлом году"
|
||||
},
|
||||
"usedCar": {
|
||||
"title": "Подержанные автомобили",
|
||||
"code": "Код",
|
||||
"search": "Поиск",
|
||||
"searchPlaceholder": "Поиск автомобиля",
|
||||
"status": "Статус",
|
||||
"statusNormal": "Норма",
|
||||
"statusArchived": "Архив",
|
||||
"statusSold": "Продан",
|
||||
"markSold": "Пометить как проданный",
|
||||
"downloadPdf": "Скачать PDF",
|
||||
"share": "Поделиться",
|
||||
"empty": "Нет подержанных автомобилей",
|
||||
"loadFailed": "Ошибка загрузки",
|
||||
"pdfDownloadFailed": "Ошибка загрузки PDF",
|
||||
"sourcePlace": "Место происхождения",
|
||||
"bodyColor": "Цвет кузова",
|
||||
"plateTime": "Дата регистрации",
|
||||
"mileage": "Пробег",
|
||||
"createdAt": "Дата создания",
|
||||
"productName": "Название продукта",
|
||||
"vin": "VIN",
|
||||
"price": "Цена",
|
||||
"displacement": "Объем двигателя",
|
||||
"power": "Мощность",
|
||||
"conditionDesc": "Описание состояния",
|
||||
"productImages": "Фотографии продукта",
|
||||
"attachments": "Вложения",
|
||||
"create": "Добавить автомобиль",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить",
|
||||
"detail": "Детали",
|
||||
"submit": "Отправить",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"confirmDelete": "Подтвердить удаление?",
|
||||
"confirmMarkSold": "Пометить как проданный?",
|
||||
"deleteSuccess": "Удалено",
|
||||
"updateSuccess": "Обновлено",
|
||||
"createSuccess": "Создано",
|
||||
"setPrice": "Установить цену",
|
||||
"tradeTerm": "Условия торговли",
|
||||
"selectTradeTerm": "Выберите условия торговли",
|
||||
"enterPrice": "Введите цену",
|
||||
"currency": "Валюта",
|
||||
"language": "Язык",
|
||||
"pdfDownloadSuccess": "PDF успешно загружен",
|
||||
"linkCopied": "Ссылка скопирована",
|
||||
"notFound": "Автомобиль не найден",
|
||||
"pageTitle": "Добавить подержанный автомобиль",
|
||||
"bind": "Привязать",
|
||||
"bindProduct": "Привязать продукт",
|
||||
"bindDialog": "Выберите модель",
|
||||
"brand": "Бренд",
|
||||
"series": "Серия",
|
||||
"bindSearchPlaceholder": "Поиск модели",
|
||||
"bindSearching": "Поиск...",
|
||||
"bindSearchFirst": "Сначала введите название продукта",
|
||||
"bindNoModels": "Модели не найдены",
|
||||
"bindNoResults": "Нет результатов",
|
||||
"bindConfirm": "Подтвердить",
|
||||
"bindClear": "Очистить",
|
||||
"bindBound": "Привязано: ",
|
||||
"bindSuccess": "Привязка успешна",
|
||||
"bindCleared": "Привязка очищена",
|
||||
"enterProductName": "Введите бренд и серию автомобиля",
|
||||
"enterProductNameFirst": "Сначала введите название продукта",
|
||||
"bindFetchFailed": "Не удалось получить продукты, попробуйте позже",
|
||||
"bindExample": "Пример: BYD Seal 07 DM",
|
||||
"enterSourcePlace": "Введите место происхождения",
|
||||
"selectPlateTime": "Выберите время",
|
||||
"enterDisplacement": "Введите объем двигателя",
|
||||
"enterPower": "Введите мощность",
|
||||
"enterMileage": "Введите пробег",
|
||||
"enterBodyColor": "Введите цвет кузова",
|
||||
"enterConditionDesc": "Введите описание состояния",
|
||||
"uploadImages": "Загрузить изображения",
|
||||
"uploadAttachments": "Загрузить вложения",
|
||||
"maxImages": "Максимум 20 изображений",
|
||||
"maxAttachments": "Максимум 20 вложений",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadFailed": "Загрузка не удалась",
|
||||
"removeImage": "Удалить",
|
||||
"view": "Просмотр",
|
||||
"requiredField": "Обязательно",
|
||||
"fillRequiredFields": "Заполните все обязательные поля",
|
||||
"confirmSubmit": "Подтвердить отправку?",
|
||||
"myPublish": "Мои объявления",
|
||||
"myGarage": "Мой гараж",
|
||||
"quickEdit": "Быстрое редактирование",
|
||||
"emptyMyPublish": "У вас пока нет опубликованных автомобилей",
|
||||
"myPage": "Мой",
|
||||
"personalInfo": "Личная информация"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"status": "状态",
|
||||
"remark": "备注",
|
||||
"createTime": "创建时间",
|
||||
"updateTime": "更新时间"
|
||||
"updateTime": "更新时间",
|
||||
"clear": "清除"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "欢迎回来",
|
||||
@@ -186,5 +187,99 @@
|
||||
"lastMonth": "上月",
|
||||
"thisYear": "今年",
|
||||
"lastYear": "去年"
|
||||
},
|
||||
"usedCar": {
|
||||
"title": "二手车",
|
||||
"code": "编号",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "搜索车辆名称",
|
||||
"status": "状态",
|
||||
"statusNormal": "正常",
|
||||
"statusArchived": "归档",
|
||||
"statusSold": "已售出",
|
||||
"markSold": "标记已售出",
|
||||
"downloadPdf": "下载PDF",
|
||||
"share": "分享",
|
||||
"empty": "暂无二手车",
|
||||
"loadFailed": "加载失败",
|
||||
"pdfDownloadFailed": "PDF下载失败",
|
||||
"sourcePlace": "车源地",
|
||||
"bodyColor": "车身颜色",
|
||||
"plateTime": "上牌时间",
|
||||
"mileage": "里程",
|
||||
"createdAt": "创建时间",
|
||||
"productName": "产品名称",
|
||||
"vin": "车架号",
|
||||
"price": "车价",
|
||||
"displacement": "排量",
|
||||
"power": "马力",
|
||||
"conditionDesc": "车况介绍",
|
||||
"productImages": "产品图",
|
||||
"attachments": "附件",
|
||||
"create": "添加二手车",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"detail": "详情",
|
||||
"submit": "提交",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirmDelete": "确认删除此二手车?",
|
||||
"confirmMarkSold": "确认标记为已售出?",
|
||||
"deleteSuccess": "删除成功",
|
||||
"updateSuccess": "更新成功",
|
||||
"createSuccess": "创建成功",
|
||||
"setPrice": "设置价格",
|
||||
"tradeTerm": "贸易条款",
|
||||
"selectTradeTerm": "请选择贸易条款",
|
||||
"enterPrice": "请输入价格",
|
||||
"currency": "币种",
|
||||
"language": "语言",
|
||||
"pdfDownloadSuccess": "PDF下载成功",
|
||||
"linkCopied": "链接已复制",
|
||||
"notFound": "未找到该车辆",
|
||||
"pageTitle": "二手车录入",
|
||||
"bind": "绑定",
|
||||
"bindProduct": "绑定产品",
|
||||
"bindDialog": "选择车型",
|
||||
"brand": "品牌",
|
||||
"series": "车系",
|
||||
"bindSearchPlaceholder": "搜索车型",
|
||||
"bindSearching": "搜索中...",
|
||||
"bindSearchFirst": "请先输入产品名称搜索",
|
||||
"bindNoModels": "未找到相关车型",
|
||||
"bindNoResults": "未找到匹配结果",
|
||||
"bindConfirm": "确认绑定",
|
||||
"bindClear": "清除",
|
||||
"bindBound": "已绑定:",
|
||||
"bindSuccess": "绑定成功",
|
||||
"bindCleared": "已清除绑定",
|
||||
"enterProductName": "请输入车辆的品牌 系列查找车型",
|
||||
"enterProductNameFirst": "请先输入产品名称",
|
||||
"bindFetchFailed": "拉取产品失败,请稍后重试",
|
||||
"bindExample": "例:比亚迪海狮07DM",
|
||||
"enterSourcePlace": "请输入车源地",
|
||||
"selectPlateTime": "请选择时间",
|
||||
"enterDisplacement": "请输入排量",
|
||||
"enterPower": "请输入马力",
|
||||
"enterMileage": "请输入里程",
|
||||
"enterBodyColor": "请输入车身颜色",
|
||||
"enterConditionDesc": "请输入车况介绍",
|
||||
"uploadImages": "上传产品图",
|
||||
"uploadAttachments": "上传附件",
|
||||
"maxImages": "最多上传20张图片",
|
||||
"maxAttachments": "最多上传20个附件",
|
||||
"uploading": "上传中...",
|
||||
"uploadFailed": "上传失败",
|
||||
"removeImage": "删除",
|
||||
"view": "查看",
|
||||
"requiredField": "必填项",
|
||||
"fillRequiredFields": "请填写所有必填项",
|
||||
"confirmSubmit": "确认提交?",
|
||||
"myPublish": "我的发布",
|
||||
"myGarage": "我的车库",
|
||||
"quickEdit": "快捷编辑",
|
||||
"emptyMyPublish": "暂无发布车辆",
|
||||
"myPage": "我的",
|
||||
"personalInfo": "个人信息"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +35,15 @@ const quickActions: QuickAction[] = [
|
||||
{ id: '1', icon: '✈️', label: '出差申请', path: '/business-trip/apply', color: '#1677ff', description: '提交出差申请' },
|
||||
{ id: '2', icon: '📝', label: '采购申请', path: '/purchase/apply', color: '#52c41a', description: '物品采购审批', disabled: true },
|
||||
{ id: '3', icon: '📦', label: '备货录入', path: '/stock/entry', color: '#faad14', description: '库存备货录入', disabled: true },
|
||||
{ id: '4', icon: '🚗', label: '发布车辆', path: '/used-car/create', color: '#722ed1', description: '发布二手车' },
|
||||
{ id: '5', icon: '📋', label: '我的发布', path: '/used-car/my-list', color: '#722ed1', description: '我发布的车辆' },
|
||||
]
|
||||
|
||||
const allApps: QuickAction[] = [
|
||||
{ id: '1', icon: '✍️', label: '审批', path: '/approval', color: '#52c41a', description: '待办事项审批' },
|
||||
{ id: '2', icon: '📊', label: '数据统计', path: '/reports', color: '#1890ff', description: '业务数据统计', disabled: true },
|
||||
{ id: '3', icon: '⚙️', label: '系统设置', path: '/settings', color: '#8c8c8c', description: '系统偏好设置' },
|
||||
{ id: '4', icon: '🚗', label: '二手车', path: '/used-car', color: '#722ed1', description: '二手车管理' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
|
||||
@@ -17,6 +17,18 @@ export default function Settings() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const settingGroups = [
|
||||
{
|
||||
title: t('profile.myInfo'),
|
||||
items: [
|
||||
{
|
||||
id: 'personal-info',
|
||||
icon: '👤',
|
||||
label: t('usedCar.personalInfo'),
|
||||
description: t('usedCar.personalInfo'),
|
||||
path: '/used-car/profile',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('profile.security'),
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
.createPage {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f8;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.createContent {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
/* 表单卡片 */
|
||||
.formCard {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 表单单元格 */
|
||||
.formCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.formCell:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.formCellVertical {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.formCellVertical .formLabel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 输入框样式 */
|
||||
.admInputWrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admInputWrapper :global(.adm-input) {
|
||||
--font-size: 15px;
|
||||
--color-text: #333;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admInputWrapper :global(.adm-input::placeholder) {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 产品名称 + 绑定按钮 */
|
||||
.inputWithAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputWithAction .admInputWrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bindBtn {
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: #2E6CF6;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bindBtn:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 绑定提示 */
|
||||
.bindTip {
|
||||
padding: 10px 16px;
|
||||
background: #F3F6FF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
margin: 0 0px 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.bindTipText {
|
||||
color: #2B3A55;
|
||||
}
|
||||
|
||||
.bindTipClear {
|
||||
color: #2E6CF6;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 选择器触发器 */
|
||||
.pickerTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 24px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pickerTrigger::after {
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #bbb;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.pickerValue {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pickerPlaceholder {
|
||||
font-size: 15px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 表单区域 */
|
||||
.formSection {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.formSectionLabel {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.imageUploadArea {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.attachmentArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.uploadBtn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
background: #2E6CF6;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.uploadBtn:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.uploadBtn:disabled {
|
||||
opacity: 0.5;
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.attachmentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachmentItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.attachmentName {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachmentDelete {
|
||||
color: #ff4d4f;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachmentDelete:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.attachmentHint {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
/* 提交栏 */
|
||||
.submitBar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background: #3F435B;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(63, 67, 91, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submitBtn:active {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ========== 绑定弹窗样式 ========== */
|
||||
|
||||
/* 遮罩层 */
|
||||
:global(.adm-modal-mask) {
|
||||
background: rgba(0, 0, 0, 0.45) !important;
|
||||
}
|
||||
|
||||
/* 弹窗容器 */
|
||||
:global(.adm-modal-body) {
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden !important;
|
||||
max-width: 420px !important;
|
||||
/* width: 92% !important; */
|
||||
max-height: 80vh !important;
|
||||
}
|
||||
|
||||
:global(.adm-modal-content) {
|
||||
padding: 0 !important;
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden !important;
|
||||
max-height: 80vh !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* 弹窗内容区域 */
|
||||
.bindDialogContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 弹窗头部 */
|
||||
.bindDialogHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bindDialogTitle {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.bindDialogClose {
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
margin: -4px -8px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bindDialogClose:hover {
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.bindDialogClose:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* 品牌/车系信息区 */
|
||||
.bindBrandInfo {
|
||||
padding: 14px 20px;
|
||||
background: linear-gradient(135deg, #F8FAFC 0%, #F3F6FF 100%);
|
||||
border-bottom: 1px solid #e8ecf0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bindBrandInfoLabel {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bindBrandInfoValue {
|
||||
font-size: 15px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bindBrandInfoSeparator {
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 搜索框区域 */
|
||||
.bindSearchArea {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bindSearch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: #F8FAFC;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bindSearch:focus-within {
|
||||
background: #fff;
|
||||
border-color: #2E6CF6;
|
||||
box-shadow: 0 0 0 3px rgba(46, 108, 246, 0.1);
|
||||
}
|
||||
|
||||
.bindSearch :global(.adm-input) {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
--font-size: 15px;
|
||||
--color-text: #333;
|
||||
}
|
||||
|
||||
.bindSearch :global(.adm-input::placeholder) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.bindSearchClear {
|
||||
color: #2E6CF6;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bindSearchClear:active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* 车型列表区域 */
|
||||
.bindList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 16px;
|
||||
background: #f8fafc;
|
||||
min-height: 200px;
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.bindList::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.bindList::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bindList::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 加载和空状态 */
|
||||
.bindLoading,
|
||||
.bindEmpty {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bindLoading::before {
|
||||
content: '';
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #2E6CF6;
|
||||
border-radius: 50%;
|
||||
animation: bindSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bindSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 车型列表项 */
|
||||
.bindItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: #fff;
|
||||
border: 2px solid transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bindItem::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(46, 108, 246, 0.05) 0%, rgba(46, 108, 246, 0.02) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.bindItem:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.bindItem.active {
|
||||
background: linear-gradient(135deg, #F0F5FF 0%, #E8F0FF 100%);
|
||||
border-color: #2E6CF6;
|
||||
box-shadow: 0 4px 12px rgba(46, 108, 246, 0.15);
|
||||
}
|
||||
|
||||
.bindItem.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bindItemMain {
|
||||
flex: 1;
|
||||
padding-right: 16px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bindItemTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemTitle {
|
||||
color: #2E6CF6;
|
||||
}
|
||||
|
||||
.bindItemSub {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bindItemSub::before {
|
||||
content: '¥';
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemSub {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* 单选按钮样式 */
|
||||
.bindItemRadio {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemRadio {
|
||||
border-color: #2E6CF6;
|
||||
background: #2E6CF6;
|
||||
box-shadow: 0 2px 8px rgba(46, 108, 246, 0.3);
|
||||
}
|
||||
|
||||
.bindItemRadioDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transform: scale(0);
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemRadioDot {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* 底部按钮区 - 固定在底部 */
|
||||
.bindDialogFooter {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.bindCancelBtn,
|
||||
.bindConfirmBtn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.bindCancelBtn {
|
||||
background: #f5f6f8;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.bindCancelBtn:active {
|
||||
background: #e8eaed;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.bindConfirmBtn {
|
||||
background: linear-gradient(135deg, #2E6CF6 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(46, 108, 246, 0.25);
|
||||
}
|
||||
|
||||
.bindConfirmBtn:active {
|
||||
transform: scale(0.97);
|
||||
box-shadow: 0 2px 6px rgba(46, 108, 246, 0.25);
|
||||
}
|
||||
|
||||
.bindConfirmBtn:disabled {
|
||||
opacity: 0.5;
|
||||
background: #e2e8f0;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bindConfirmBtn:disabled:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 图片上传区域 */
|
||||
.admImageUploader {
|
||||
--cell-size: 80px;
|
||||
}
|
||||
|
||||
.admImageUploader :global(.adm-image-uploader-cell-mask) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* TextArea 样式 */
|
||||
.admTextAreaWrapper :global(.adm-text-area) {
|
||||
--font-size: 15px;
|
||||
--color-text: #333;
|
||||
background: #f9f9f9;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 兼容旧的 checkDot 类名 */
|
||||
.checkDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transform: scale(0);
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* 兼容旧的 bindItemCheck 类名 */
|
||||
.bindItemCheck {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemCheck {
|
||||
border-color: #2E6CF6;
|
||||
background: #2E6CF6;
|
||||
box-shadow: 0 2px 8px rgba(46, 108, 246, 0.3);
|
||||
}
|
||||
|
||||
.bindItem.active .checkDot {
|
||||
transform: scale(1);
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Input,
|
||||
Button,
|
||||
DatePicker,
|
||||
Toast,
|
||||
TextArea,
|
||||
ImageUploader,
|
||||
Modal
|
||||
} from 'antd-mobile'
|
||||
import { uploadApi, usedCarApi } from '@/services/api'
|
||||
import styles from './create.module.css'
|
||||
|
||||
// 车型接口
|
||||
interface CarModel {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
msrp: number
|
||||
}
|
||||
|
||||
// 关键词搜索结果接口
|
||||
interface KeywordSearchResult {
|
||||
brand: string
|
||||
series: string
|
||||
series_id: string
|
||||
models: CarModel[]
|
||||
}
|
||||
|
||||
// 绑定状态接口
|
||||
interface BoundProduct {
|
||||
brand?: string
|
||||
series?: string
|
||||
series_id?: string
|
||||
model?: CarModel
|
||||
}
|
||||
|
||||
// 图片文件项接口
|
||||
interface ImageFileItem {
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
const UsedCarCreate: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({
|
||||
product_name: '',
|
||||
vin: '',
|
||||
price: '',
|
||||
source_place: '',
|
||||
plate_place: '',
|
||||
plate_time: '',
|
||||
displacement: '',
|
||||
power: '',
|
||||
body_color: '',
|
||||
condition_desc: '',
|
||||
mileage: '',
|
||||
// 绑定车型数据字段
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
|
||||
// 图片和附件
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [attachments, setAttachments] = useState<Array<{ id: number; name: string; url: string; size: number }>>([])
|
||||
|
||||
// 绑定弹窗状态
|
||||
const [bindVisible, setBindVisible] = useState(false)
|
||||
const [bindLoading, setBindLoading] = useState(false)
|
||||
const [bindResult, setBindResult] = useState<KeywordSearchResult | null>(null)
|
||||
const [bindSelected, setBindSelected] = useState<CarModel | null>(null)
|
||||
const [boundProduct, setBoundProduct] = useState<BoundProduct | null>(null)
|
||||
const [bindKeyword, setBindKeyword] = useState('')
|
||||
|
||||
// 提交中
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 上牌时间选择
|
||||
const [plateTimeVisible, setPlateTimeVisible] = useState(false)
|
||||
|
||||
// 产品名称绑定(关键词搜索)
|
||||
const handleBindByName = async () => {
|
||||
const name = formData.product_name.trim()
|
||||
if (!name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setBindVisible(true)
|
||||
setBindLoading(true)
|
||||
setBindResult(null)
|
||||
setBindSelected(null)
|
||||
setBindKeyword('')
|
||||
|
||||
try {
|
||||
const res = await usedCarApi.crawlKeyword(name)
|
||||
setBindResult(res)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.bindFetchFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setBindLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认绑定
|
||||
const handleConfirmBind = () => {
|
||||
if (!bindSelected || !bindResult) return
|
||||
|
||||
setBoundProduct({
|
||||
brand: bindResult.brand,
|
||||
series: bindResult.series,
|
||||
series_id: bindResult.series_id,
|
||||
model: bindSelected
|
||||
})
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: bindResult.brand,
|
||||
crawl_series: bindResult.series,
|
||||
crawl_series_id: bindResult.series_id,
|
||||
crawl_model_name: bindSelected.name,
|
||||
crawl_model_url: bindSelected.url,
|
||||
crawl_msrp: bindSelected.msrp ? String(bindSelected.msrp) : ''
|
||||
})
|
||||
|
||||
setBindVisible(false)
|
||||
Toast.show({ content: t('usedCar.bindSuccess'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 清除绑定
|
||||
const handleClearBind = () => {
|
||||
setBoundProduct(null)
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
Toast.show({ content: t('usedCar.bindCleared'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 过滤车型列表
|
||||
const filteredModels = bindResult?.models.filter((model) => {
|
||||
const kw = bindKeyword.toLowerCase().trim()
|
||||
if (!kw) return true
|
||||
const name = String(model.name || '').toLowerCase()
|
||||
return name.includes(kw)
|
||||
}) || []
|
||||
|
||||
// 选择车型
|
||||
const handleSelectModel = (model: CarModel) => {
|
||||
setBindSelected(model)
|
||||
}
|
||||
|
||||
// 跟踪上传数量
|
||||
const [uploadingCount, setUploadingCount] = useState(0)
|
||||
|
||||
// 图片上传处理(在 ImageUploader 的 upload prop 中调用)
|
||||
const handleImageUpload = async (file: File): Promise<{ url: string }> => {
|
||||
setUploadingCount(prev => prev + 1)
|
||||
try {
|
||||
const result: any = await uploadApi.uploadUsedCarImage(file)
|
||||
return { url: result.full_url || result.url }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
} finally {
|
||||
setUploadingCount(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片变化处理
|
||||
const handleImageChange = (newFiles: ImageFileItem[]) => {
|
||||
// 更新图片列表
|
||||
setImages(newFiles.map(f => f.url))
|
||||
}
|
||||
|
||||
// 图片删除处理
|
||||
const handleImageDelete = (deletedFile: ImageFileItem) => {
|
||||
const newImages = images.filter(url => url !== deletedFile.url)
|
||||
setImages(newImages)
|
||||
}
|
||||
|
||||
// 附件上传处理
|
||||
const handleAttachmentUpload = async (file: File): Promise<{ id: number; name: string; url: string; size: number }> => {
|
||||
try {
|
||||
const result: any = await uploadApi.uploadFile(file, 'other', 'usedcar')
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.file_name || file.name,
|
||||
url: result.url,
|
||||
size: result.file_size || file.size
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 附件选择处理
|
||||
const handleAttachmentSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const newAttachments = [...attachments]
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (newAttachments.length + i >= 20) {
|
||||
Toast.show({ content: t('usedCar.maxAttachments'), icon: 'fail' })
|
||||
break
|
||||
}
|
||||
try {
|
||||
const result = await handleAttachmentUpload(file)
|
||||
newAttachments.push(result)
|
||||
} catch (err) {
|
||||
// 上传失败已在 handleAttachmentUpload 中处理
|
||||
}
|
||||
}
|
||||
setAttachments(newAttachments)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 附件删除处理
|
||||
const handleAttachmentDelete = (index: number) => {
|
||||
const newAttachments = attachments.filter((_, i) => i !== index)
|
||||
setAttachments(newAttachments)
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.product_name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
// 检查是否有正在上传的图片
|
||||
if (uploadingCount > 0) {
|
||||
return Toast.show({ content: t('usedCar.uploading'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
price: formData.price ? Number(formData.price) : undefined,
|
||||
mileage: formData.mileage ? Number(formData.mileage) : undefined,
|
||||
crawl_msrp: formData.crawl_msrp ? Number(formData.crawl_msrp) : undefined,
|
||||
// 图片数组转为逗号分隔字符串
|
||||
images: images.join(','),
|
||||
// 附件数组转为JSON
|
||||
attachment: attachments,
|
||||
}
|
||||
|
||||
const id = await usedCarApi.create(payload)
|
||||
if (id) {
|
||||
Toast.show({ content: t('usedCar.createSuccess'), icon: 'success' })
|
||||
setTimeout(() => {
|
||||
navigate('/used-car')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('common.error')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.createPage}>
|
||||
{/* <NavBar onBack={() => navigate(-1)}>{t('usedCar.pageTitle')}</NavBar> */}
|
||||
|
||||
<div className={styles.createContent}>
|
||||
{/* 产品名称卡片 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={`${styles.formCell} ${styles.formCellVertical}`}>
|
||||
<label className={styles.formLabel}>
|
||||
{t('usedCar.productName')}
|
||||
<span className={styles.required}>*</span>
|
||||
</label>
|
||||
<div className={styles.inputWithAction}>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterProductName')}
|
||||
value={formData.product_name}
|
||||
onChange={(value) => setFormData({ ...formData, product_name: value })}
|
||||
/>
|
||||
</div>
|
||||
<button className={styles.bindBtn} onClick={handleBindByName}>
|
||||
{t('usedCar.search')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 绑定状态提示 */}
|
||||
{boundProduct ? (
|
||||
<div className={styles.bindTip}>
|
||||
<span className={styles.bindTipText}>
|
||||
{t('usedCar.bindBound')}: {boundProduct.brand} · {boundProduct.series}
|
||||
{boundProduct.model && <span> · {boundProduct.model.name}</span>}
|
||||
</span>
|
||||
<span className={styles.bindTipClear} onClick={handleClearBind}>
|
||||
{t('usedCar.bindClear')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.bindTip}>
|
||||
<span>{t('usedCar.bindExample')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.price')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice')}
|
||||
value={formData.price}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, price: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.sourcePlace')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterSourcePlace')}
|
||||
value={formData.source_place}
|
||||
onChange={(value) => setFormData({ ...formData, source_place: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.plateTime')}</label>
|
||||
<div className={styles.pickerTrigger} onClick={() => setPlateTimeVisible(true)}>
|
||||
<span className={formData.plate_time ? styles.pickerValue : styles.pickerPlaceholder}>
|
||||
{formData.plate_time || t('usedCar.selectPlateTime')}
|
||||
</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={plateTimeVisible}
|
||||
onClose={() => setPlateTimeVisible(false)}
|
||||
onConfirm={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
setFormData({ ...formData, plate_time: dateStr })
|
||||
setPlateTimeVisible(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.displacement')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterDisplacement')}
|
||||
value={formData.displacement}
|
||||
onChange={(value) => setFormData({ ...formData, displacement: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.power')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPower')}
|
||||
value={formData.power}
|
||||
onChange={(value) => setFormData({ ...formData, power: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.mileage')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterMileage')}
|
||||
value={formData.mileage}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, mileage: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.bodyColor')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterBodyColor')}
|
||||
value={formData.body_color}
|
||||
onChange={(value) => setFormData({ ...formData, body_color: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品图 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.productImages')}</label>
|
||||
<div className={styles.imageUploadArea}>
|
||||
<ImageUploader
|
||||
value={images.map((url) => ({ url }))}
|
||||
onChange={handleImageChange}
|
||||
onDelete={handleImageDelete}
|
||||
upload={handleImageUpload}
|
||||
maxCount={20}
|
||||
multiple
|
||||
showUpload
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 附件 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.attachments')}</label>
|
||||
<div className={styles.attachmentArea}>
|
||||
<input
|
||||
type="file"
|
||||
id="attachment-upload"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleAttachmentSelect}
|
||||
/>
|
||||
<button
|
||||
className={styles.uploadBtn}
|
||||
onClick={() => document.getElementById('attachment-upload')?.click()}
|
||||
disabled={attachments.length >= 20}
|
||||
>
|
||||
{t('usedCar.uploadAttachments')}
|
||||
</button>
|
||||
<div className={styles.attachmentList}>
|
||||
{attachments.map((file, index) => (
|
||||
<div key={file.id} className={styles.attachmentItem}>
|
||||
<span className={styles.attachmentName}>{file.name}</span>
|
||||
<span
|
||||
className={styles.attachmentDelete}
|
||||
onClick={() => handleAttachmentDelete(index)}
|
||||
>
|
||||
{t('usedCar.removeImage')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 车况介绍 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.conditionDesc')}</label>
|
||||
<div className={styles.admTextAreaWrapper}>
|
||||
<TextArea
|
||||
placeholder={t('usedCar.enterConditionDesc')}
|
||||
value={formData.condition_desc}
|
||||
onChange={(value) => setFormData({ ...formData, condition_desc: value })}
|
||||
rows={4}
|
||||
autoSize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部提交按钮 */}
|
||||
<div className={styles.submitBar}>
|
||||
<Button
|
||||
block
|
||||
className={styles.submitBtn}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={uploadingCount > 0}
|
||||
>
|
||||
{uploadingCount > 0 ? t('usedCar.uploading') : t('usedCar.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 绑定车型弹窗 */}
|
||||
<Modal
|
||||
visible={bindVisible}
|
||||
content={
|
||||
<div className={styles.bindDialogContent}>
|
||||
{/* 弹窗头部 */}
|
||||
<div className={styles.bindDialogHeader}>
|
||||
<span className={styles.bindDialogTitle}>{t('usedCar.bindDialog')}</span>
|
||||
<span className={styles.bindDialogClose} onClick={() => setBindVisible(false)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 品牌/车系信息 */}
|
||||
{bindResult && (
|
||||
<div className={styles.bindBrandInfo}>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.brand')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.brand}</span>
|
||||
<span className={styles.bindBrandInfoSeparator}>·</span>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.series')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.series}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索框 */}
|
||||
{bindResult && bindResult.models.length > 0 && (
|
||||
<div className={styles.bindSearchArea}>
|
||||
<div className={styles.bindSearch}>
|
||||
<Input
|
||||
placeholder={t('usedCar.bindSearchPlaceholder')}
|
||||
value={bindKeyword}
|
||||
onChange={(value) => setBindKeyword(value)}
|
||||
/>
|
||||
{bindKeyword && (
|
||||
<span className={styles.bindSearchClear} onClick={() => setBindKeyword('')}>
|
||||
{t('usedCar.clear')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车型列表 */}
|
||||
<div className={styles.bindList}>
|
||||
{bindLoading ? (
|
||||
<div className={styles.bindLoading}>{t('usedCar.bindSearching')}</div>
|
||||
) : !bindResult ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindSearchFirst')}</div>
|
||||
) : bindResult.models.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoModels')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoResults')}</div>
|
||||
) : (
|
||||
filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`${styles.bindItem} ${bindSelected?.id === model.id ? styles.active : ''}`}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
>
|
||||
<div className={styles.bindItemMain}>
|
||||
<div className={styles.bindItemTitle}>{model.name}</div>
|
||||
{model.msrp > 0 && (
|
||||
<div className={styles.bindItemSub}>
|
||||
{(model.msrp / 10000).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.bindItemRadio}>
|
||||
<div className={styles.bindItemRadioDot} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className={styles.bindDialogFooter}>
|
||||
<button
|
||||
className={styles.bindCancelBtn}
|
||||
onClick={() => setBindVisible(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.bindConfirmBtn}
|
||||
disabled={!bindSelected}
|
||||
onClick={handleConfirmBind}
|
||||
>
|
||||
{t('usedCar.bindConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
closeOnMaskClick
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarCreate
|
||||
@@ -0,0 +1,550 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f6f7fb;
|
||||
}
|
||||
|
||||
.loadingContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 9px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
background: #fff;
|
||||
padding: 9px 9px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridImg {
|
||||
width: calc((100% - 10px) / 3);
|
||||
height: 110px;
|
||||
border-radius: 7px;
|
||||
background: #f3f4f6;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-top: 8px;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
padding: 9px 9px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.heard {
|
||||
padding: 2px 0px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.heard > div:last-child {
|
||||
cursor: pointer;
|
||||
color: #3F435B;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.heard > div:last-child:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kvItem {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.kvItem:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.k {
|
||||
width: 90px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.v {
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.fujian {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid #ebebeb;
|
||||
padding: 4px 5px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.fujian:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fujian:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.fujian p {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fujian > div {
|
||||
padding: 3px 7px;
|
||||
background: #3F435B;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.noneview {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.noneview img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 价格弹窗样式 - 美化版 */
|
||||
.priceDialog {
|
||||
/* width: 100%; */
|
||||
/* max-width: 340px; */
|
||||
/* background: linear-gradient(180deg, #ffffff 0%, #fafbfc 100%); */
|
||||
/* border-radius: 20px; */
|
||||
padding: 24px 20px 20px;
|
||||
/* box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.06); */
|
||||
}
|
||||
|
||||
.pdTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
letter-spacing: -0.3px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.pdRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pdRow:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.pdLabel {
|
||||
width: 70px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex-shrink: 0;
|
||||
padding-top: 11px;
|
||||
}
|
||||
|
||||
.pdPriceLine {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tradeTerm {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
font-size: 15px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.tradeTerm:focus {
|
||||
outline: none;
|
||||
border-color: #3F435B;
|
||||
box-shadow: 0 0 0 3px rgba(63, 67, 91, 0.1);
|
||||
}
|
||||
|
||||
.pdInputSuffixWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pdInputPrice {
|
||||
width: 100%;
|
||||
height: 44px !important;
|
||||
border: 2px solid #e8e8e8 !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 0 14px !important;
|
||||
font-size: 15px !important;
|
||||
transition: all 0.2s;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.pdInputPrice:focus {
|
||||
border-color: #3F435B !important;
|
||||
box-shadow: 0 0 0 3px rgba(63, 67, 91, 0.1) !important;
|
||||
}
|
||||
|
||||
.pdCurrencySuffix {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 80px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.pdCurrencySuffix select {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
padding: 0 4px 0 8px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 4px center;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.pdRadioGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pdRadioGroup :global(.adm-radio) {
|
||||
--checked-color: #3F435B;
|
||||
--size: 18px;
|
||||
}
|
||||
|
||||
.pdRadioGroup :global(.adm-radio .adm-radio-content) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pdRadioText {
|
||||
margin-left: 6px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pdActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.pdCancel,
|
||||
.pdConfirm {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pdCancel {
|
||||
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pdCancel:active {
|
||||
background: linear-gradient(135deg, #e8e8e8 0%, #d4d4d4 100%);
|
||||
}
|
||||
|
||||
.pdConfirm {
|
||||
background: linear-gradient(345deg, #3F435B 0%, #4a4f6a 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(63, 67, 91, 0.3);
|
||||
}
|
||||
|
||||
.pdConfirm:active {
|
||||
box-shadow: 0 2px 6px rgba(63, 67, 91, 0.3);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* 新详情页样式 */
|
||||
.detailPage {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.detailContent {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.codeBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionValue {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.infoRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.imageItem {
|
||||
position: relative;
|
||||
padding-bottom: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.imageThumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachmentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachmentItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: #f8f8f8;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachmentName {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachmentDownload {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timeInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 价格弹窗样式 */
|
||||
.priceModalContent {
|
||||
padding: 16px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.formItem {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.formItem:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.selectWrapper select {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Button,
|
||||
Modal,
|
||||
Input,
|
||||
Toast,
|
||||
ImageViewer,
|
||||
Empty,
|
||||
Grid
|
||||
} from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import styles from './detail.module.css'
|
||||
|
||||
// 贸易条款选项
|
||||
const TRADE_TERMS = [
|
||||
{ value: 'FOB', text: 'FOB' },
|
||||
{ value: 'CIF', text: 'CIF' },
|
||||
{ value: 'EXW', text: 'EXW' },
|
||||
{ value: 'DDP', text: 'DDP' },
|
||||
{ value: 'CIP', text: 'CIP' },
|
||||
{ value: 'FCA', text: 'FCA' },
|
||||
{ value: 'CFR', text: 'CFR' },
|
||||
]
|
||||
|
||||
// 币种选项
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', text: '美元' },
|
||||
{ value: 'CNY', text: '人民币' },
|
||||
{ value: 'RUB', text: '卢布' },
|
||||
{ value: 'AED', text: '迪拉姆' },
|
||||
{ value: 'EUR', text: '欧元' }
|
||||
]
|
||||
|
||||
// 语言选项
|
||||
const LANGUAGES = [
|
||||
{ value: 'en', text: '英语' },
|
||||
{ value: 'ru', text: '俄语' },
|
||||
{ value: 'es', text: '西班牙语' },
|
||||
{ value: 'ar', text: '阿拉伯语' }
|
||||
]
|
||||
|
||||
interface UsedCarDetail {
|
||||
id: number
|
||||
code: string
|
||||
product_name: string
|
||||
vin?: string
|
||||
price?: number
|
||||
source_place?: string
|
||||
plate_place?: string
|
||||
plate_time?: string
|
||||
displacement?: string
|
||||
mileage?: number
|
||||
power?: string
|
||||
body_color?: string
|
||||
condition_desc?: string
|
||||
images?: string
|
||||
images_pull?: Array<{ url: string; name?: string }>
|
||||
images_array?: string[]
|
||||
attachment?: string
|
||||
attachment_pull?: Array<{ url: string; name: string; src_pull?: string }>
|
||||
attachment_array?: Array<{ url: string; name: string }>
|
||||
pdf_src?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// H5 下载:用 a 标签触发
|
||||
const downloadByAnchor = (url: string, filename?: string) => {
|
||||
try {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
if (filename) {
|
||||
a.download = filename
|
||||
}
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch {
|
||||
// 兜底:直接打开
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const UsedCarDetail: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
|
||||
const [detail, setDetail] = useState<UsedCarDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 图片预览
|
||||
const [imageViewVisible, setImageViewVisible] = useState(false)
|
||||
const [imageViewIndex, setImageViewIndex] = useState(0)
|
||||
|
||||
// 价格弹窗
|
||||
const [priceModalVisible, setPriceModalVisible] = useState(false)
|
||||
const [priceData, setPriceData] = useState({
|
||||
price: '',
|
||||
currency: 'USD',
|
||||
lang: 'en',
|
||||
trade_term: 'FOB'
|
||||
})
|
||||
const [generatingPdf, setGeneratingPdf] = useState(false)
|
||||
|
||||
// 处理图片 URL(如果是相对路径或 localhost 则转换为完整 URL)
|
||||
const processImageUrl = (url: string): string => {
|
||||
if (!url) return ''
|
||||
// 如果已经是完整 URL 且不是 localhost,直接返回
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
// 如果是 localhost,替换为实际后端地址
|
||||
if (url.includes('http://localhost/')) {
|
||||
return url.replace('http://localhost/', 'http://localhost:8787/')
|
||||
}
|
||||
if (url.includes('http://localhost:')) {
|
||||
return url
|
||||
}
|
||||
return url
|
||||
}
|
||||
// 相对路径,添加后端地址
|
||||
return `http://localhost:8787${url.startsWith('/') ? '' : '/'}${url}`
|
||||
}
|
||||
|
||||
// 获取图片列表
|
||||
const getImages = (): string[] => {
|
||||
if (!detail) return []
|
||||
// 优先使用 images_array(后端返回的主要字段)
|
||||
if (detail.images_array && detail.images_array.length > 0) {
|
||||
return detail.images_array.map(processImageUrl)
|
||||
}
|
||||
if (detail.images_pull && detail.images_pull.length > 0) {
|
||||
return detail.images_pull.map((img: { url?: string } | string) => {
|
||||
const url = typeof img === 'string' ? img : img.url
|
||||
return processImageUrl(url || '')
|
||||
})
|
||||
}
|
||||
if (detail.images) {
|
||||
try {
|
||||
const parsed = JSON.parse(detail.images)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(processImageUrl)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 获取附件列表
|
||||
const getAttachments = (): Array<{ url: string; name: string }> => {
|
||||
if (!detail) return []
|
||||
// 优先使用 attachment_array(后端返回的主要字段)
|
||||
if (detail.attachment_array && detail.attachment_array.length > 0) {
|
||||
return detail.attachment_array.map(att => ({
|
||||
url: processImageUrl(att.url),
|
||||
name: att.name
|
||||
}))
|
||||
}
|
||||
if (detail.attachment_pull && detail.attachment_pull.length > 0) {
|
||||
return detail.attachment_pull.map(att => ({
|
||||
url: processImageUrl(att.url || att.src_pull || ''),
|
||||
name: att.name
|
||||
}))
|
||||
}
|
||||
if (detail.attachment) {
|
||||
try {
|
||||
const parsed = JSON.parse(detail.attachment) as Array<{ url?: string; name?: string }>
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((att) => ({
|
||||
url: processImageUrl(att.url || ''),
|
||||
name: att.name || ''
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 加载详情
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
if (!id) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 响应拦截器已经返回 data.data,直接使用即可
|
||||
const detailData = await usedCarApi.getDetail(Number(id))
|
||||
setDetail(detailData as UsedCarDetail)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.loadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
navigate(-1)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDetail()
|
||||
}, [id])
|
||||
|
||||
// 下载PDF - 如果存在 URL 直接下载,否则请求价格
|
||||
const handleDownloadPdf = () => {
|
||||
if (!detail) return
|
||||
|
||||
// 如果有 PDF URL,直接下载
|
||||
if (detail.pdf_src && detail.pdf_src.trim() !== '') {
|
||||
downloadByAnchor(detail.pdf_src, `${detail.product_name}-${detail.code}.pdf`)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有 PDF URL,弹出价格输入框
|
||||
setPriceModalVisible(true)
|
||||
}
|
||||
|
||||
// 生成PDF
|
||||
const handleGeneratePdf = async () => {
|
||||
if (!detail || !priceData.price) {
|
||||
return Toast.show({ content: t('usedCar.enterPrice'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setGeneratingPdf(true)
|
||||
try {
|
||||
const res = await usedCarApi.generatePdf({
|
||||
id: detail.id,
|
||||
price: Number(priceData.price),
|
||||
currency: priceData.currency,
|
||||
lang: priceData.lang,
|
||||
trade_term: priceData.trade_term
|
||||
}) as { pdf_url?: string; file_name?: string }
|
||||
|
||||
if (res.pdf_url) {
|
||||
downloadByAnchor(res.pdf_url, res.file_name || `${detail.product_name}-${detail.code}.pdf`)
|
||||
Toast.show({ content: t('usedCar.pdfDownloadSuccess') || 'PDF 下载成功', icon: 'success' })
|
||||
setPriceModalVisible(false)
|
||||
// 刷新详情
|
||||
const refreshedDetail = await usedCarApi.getDetail(Number(id))
|
||||
setDetail(refreshedDetail as UsedCarDetail)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : (t('usedCar.pdfDownloadFailed') || 'PDF 生成失败')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setGeneratingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 分享
|
||||
const handleShare = async () => {
|
||||
if (!detail) return
|
||||
const title = detail.product_name || '车辆详情'
|
||||
const link = `${window.location.origin}/#/used-car/detail/${detail.id}`
|
||||
const images = getImages()
|
||||
|
||||
// 检测是否在飞书环境
|
||||
const isFeishuEnv = /feishu|lark/i.test(navigator.userAgent)
|
||||
|
||||
if (isFeishuEnv) {
|
||||
try {
|
||||
// 等待飞书 SDK 准备就绪
|
||||
const waitForFeishuSDK = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.tt && (window as any).h5sdk) {
|
||||
(window as any).h5sdk.ready(() => resolve())
|
||||
return
|
||||
}
|
||||
let attempts = 0
|
||||
const maxAttempts = 50
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++
|
||||
if (window.tt && (window as any).h5sdk) {
|
||||
clearInterval(checkInterval)
|
||||
;(window as any).h5sdk.ready(() => resolve())
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(checkInterval)
|
||||
reject(new Error('TT_OBJECT_TIMEOUT'))
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
await waitForFeishuSDK()
|
||||
|
||||
// 调用飞书分享
|
||||
;(window as any).tt.shareWebContent({
|
||||
title,
|
||||
content: title,
|
||||
url: link,
|
||||
imageUrl: images[0] || '',
|
||||
success: () => {
|
||||
Toast.show({ content: '已唤起飞书分享', icon: 'success' })
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('飞书分享失败:', err)
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('飞书 SDK 加载失败:', error)
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, text: title, url: link }).catch(() => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
})
|
||||
})
|
||||
} else {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 非飞书环境,使用 Web Share API
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, text: title, url: link }).catch(() => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 最后的兜底:复制链接
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = () => {
|
||||
navigate(`/used-car/edit/${id}`)
|
||||
}
|
||||
|
||||
// 图片查看
|
||||
const images = getImages()
|
||||
const attachments = getAttachments()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.detailPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.detail')}</NavBar>
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#999' }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className={styles.detailPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.detail')}</NavBar>
|
||||
<Empty description={t('usedCar.notFound')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.detailPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.detail')}</NavBar>
|
||||
|
||||
<div className={styles.detailContent}>
|
||||
{/* 编码 */}
|
||||
<div className={styles.codeBar}>
|
||||
<span className={styles.code}>{t('usedCar.code')}: {detail.code || '~'}</span>
|
||||
{/* <Button size="mini" color="primary" onClick={handleEdit}>
|
||||
{t('usedCar.edit')}
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
{/* 产品名称 */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('usedCar.productName')}</div>
|
||||
<div className={styles.sectionValue}>{detail.product_name || '-'}</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息卡片 */}
|
||||
<div className={styles.infoCard}>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.price')}</span>
|
||||
<span className={styles.infoValue}>{detail.price ? `¥${detail.price}` : '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.sourcePlace')}</span>
|
||||
<span className={styles.infoValue}>{detail.source_place || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.platePlace')}</span>
|
||||
<span className={styles.infoValue}>{detail.plate_place || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.plateTime')}</span>
|
||||
<span className={styles.infoValue}>{detail.plate_time ? String(detail.plate_time).slice(0, 7) : '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.displacement')}</span>
|
||||
<span className={styles.infoValue}>{detail.displacement || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.power')}</span>
|
||||
<span className={styles.infoValue}>{detail.power || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.mileage')}</span>
|
||||
<span className={styles.infoValue}>{detail.mileage ? `${detail.mileage} Km` : '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.bodyColor')}</span>
|
||||
<span className={styles.infoValue}>{detail.body_color || '-'}</span>
|
||||
</div>
|
||||
{detail.vin && (
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>VIN</span>
|
||||
<span className={styles.infoValue}>{detail.vin}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片 */}
|
||||
{images.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('usedCar.productImages')}</div>
|
||||
<Grid columns={3} gap={8}>
|
||||
{images.map((url, index) => (
|
||||
<Grid.Item key={index}>
|
||||
<div
|
||||
className={styles.imageItem}
|
||||
onClick={() => {
|
||||
setImageViewIndex(index)
|
||||
setImageViewVisible(true)
|
||||
}}
|
||||
>
|
||||
<img src={url} alt="" className={styles.imageThumb} />
|
||||
</div>
|
||||
</Grid.Item>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车况介绍 */}
|
||||
{detail.condition_desc && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('usedCar.conditionDesc')}</div>
|
||||
<div className={styles.sectionValue}>{detail.condition_desc}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 附件 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('usedCar.attachments')}</div>
|
||||
<div className={styles.attachmentList}>
|
||||
{attachments.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.attachmentItem}
|
||||
onClick={() => downloadByAnchor(file.url, file.name)}
|
||||
>
|
||||
<span className={styles.attachmentName}>{file.name}</span>
|
||||
<span className={styles.attachmentDownload}>📥</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className={styles.timeInfo}>
|
||||
<span>{t('usedCar.createdAt')}: {detail.created_at}</span>
|
||||
{detail.updated_at !== detail.created_at && (
|
||||
<span>{t('usedCar.updatedAt')}: {detail.updated_at}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className={styles.bottomBar}>
|
||||
<Button className={styles.actionBtn} onClick={handleDownloadPdf}>
|
||||
📥 {t('usedCar.downloadPdf')}
|
||||
</Button>
|
||||
<Button className={styles.actionBtn} onClick={handleShare}>
|
||||
🔗 {t('usedCar.share')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageViewer
|
||||
image={images[imageViewIndex]}
|
||||
visible={imageViewVisible}
|
||||
onClose={() => setImageViewVisible(false)}
|
||||
/>
|
||||
|
||||
{/* 价格输入弹窗 */}
|
||||
<Modal
|
||||
visible={priceModalVisible}
|
||||
title={t('usedCar.generatePdf')}
|
||||
onClose={() => setPriceModalVisible(false)}
|
||||
content={
|
||||
<div className={styles.priceModalContent}>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formLabel}>{t('usedCar.price')}</label>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice')}
|
||||
type="number"
|
||||
value={priceData.price}
|
||||
onChange={value => setPriceData({ ...priceData, price: value })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formLabel}>{t('usedCar.currency')}</label>
|
||||
<div className={styles.selectWrapper}>
|
||||
<select
|
||||
value={priceData.currency}
|
||||
onChange={e => setPriceData({ ...priceData, currency: e.target.value })}
|
||||
>
|
||||
{CURRENCIES.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.text}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formLabel}>{t('usedCar.language')}</label>
|
||||
<div className={styles.selectWrapper}>
|
||||
<select
|
||||
value={priceData.lang}
|
||||
onChange={e => setPriceData({ ...priceData, lang: e.target.value })}
|
||||
>
|
||||
{LANGUAGES.map(l => (
|
||||
<option key={l.value} value={l.value}>{l.text}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formLabel}>{t('usedCar.tradeTerm')}</label>
|
||||
<div className={styles.selectWrapper}>
|
||||
<select
|
||||
value={priceData.trade_term}
|
||||
onChange={e => setPriceData({ ...priceData, trade_term: e.target.value })}
|
||||
>
|
||||
{TRADE_TERMS.map(term => (
|
||||
<option key={term.value} value={term.value}>{term.text}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
loading={generatingPdf}
|
||||
onClick={async () => {
|
||||
await handleGeneratePdf()
|
||||
}}
|
||||
>
|
||||
{t('usedCar.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarDetail
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Button,
|
||||
Modal,
|
||||
Input,
|
||||
Toast,
|
||||
ImageViewer,
|
||||
Empty
|
||||
} from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import styles from './detail.module.css'
|
||||
|
||||
// 贸易条款选项
|
||||
const TRADE_TERMS = [
|
||||
{ value: 'FOB', text: 'FOB' },
|
||||
{ value: 'CIF', text: 'CIF' },
|
||||
{ value: 'EXW', text: 'EXW' },
|
||||
{ value: 'DDP', text: 'DDP' },
|
||||
{ value: 'CIP', text: 'CIP' },
|
||||
{ value: 'FCA', text: 'FCA' },
|
||||
{ value: 'CFR', text: 'CFR' },
|
||||
]
|
||||
|
||||
// 币种选项
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', text: '美元' },
|
||||
{ value: 'CNY', text: '人民币' },
|
||||
{ value: 'RUB', text: '卢布' },
|
||||
{ value: 'AED', text: '迪拉姆' },
|
||||
{ value: 'EUR', text: '欧元' }
|
||||
]
|
||||
|
||||
// 语言选项
|
||||
const LANGUAGES = [
|
||||
{ value: 'en', text: '英语' },
|
||||
{ value: 'ru', text: '俄语' },
|
||||
{ value: 'es', text: '西班牙语' },
|
||||
{ value: 'ar', text: '阿拉伯语' }
|
||||
]
|
||||
|
||||
interface UsedCarDetail {
|
||||
id: number
|
||||
code: string
|
||||
product_name: string
|
||||
vin?: string
|
||||
price?: number
|
||||
source_place?: string
|
||||
plate_place?: string
|
||||
plate_time?: string
|
||||
displacement?: string
|
||||
mileage?: number
|
||||
power?: string
|
||||
body_color?: string
|
||||
condition_desc?: string
|
||||
images?: string
|
||||
images_pull?: Array<{ url: string; name?: string }>
|
||||
images_array?: string[]
|
||||
attachment?: string
|
||||
attachment_pull?: Array<{ url: string; name: string; src_pull?: string }>
|
||||
attachment_array?: Array<{ url: string; name: string }>
|
||||
pdf_src?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Input,
|
||||
Button,
|
||||
DatePicker,
|
||||
Toast,
|
||||
TextArea,
|
||||
ImageUploader,
|
||||
Modal
|
||||
} from 'antd-mobile'
|
||||
import { uploadApi, usedCarApi } from '@/services/api'
|
||||
import styles from './create.module.css'
|
||||
|
||||
// 车型接口
|
||||
interface CarModel {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
msrp: number
|
||||
}
|
||||
|
||||
// 关键词搜索结果接口
|
||||
interface KeywordSearchResult {
|
||||
brand: string
|
||||
series: string
|
||||
series_id: string
|
||||
models: CarModel[]
|
||||
}
|
||||
|
||||
// 绑定状态接口
|
||||
interface BoundProduct {
|
||||
brand?: string
|
||||
series?: string
|
||||
series_id?: string
|
||||
model?: CarModel
|
||||
}
|
||||
|
||||
// 二手车详情接口
|
||||
interface UsedCarDetail {
|
||||
id: number
|
||||
code: string
|
||||
product_name: string
|
||||
vin?: string
|
||||
price?: number
|
||||
source_place?: string
|
||||
plate_place?: string
|
||||
plate_time?: string
|
||||
displacement?: string
|
||||
mileage?: number
|
||||
power?: string
|
||||
body_color?: string
|
||||
condition_desc?: string
|
||||
images?: string
|
||||
images_pull?: Array<{ url: string }>
|
||||
images_array?: string[]
|
||||
attachment?: string
|
||||
attachment_pull?: Array<{ url: string; name: string }>
|
||||
attachment_array?: Array<{ url: string; name: string }>
|
||||
crawl_brand?: string
|
||||
crawl_series?: string
|
||||
crawl_series_id?: string
|
||||
crawl_model_name?: string
|
||||
crawl_model_url?: string
|
||||
crawl_msrp?: string
|
||||
}
|
||||
|
||||
// 图片文件项接口
|
||||
interface ImageFileItem {
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
const UsedCarEdit: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({
|
||||
product_name: '',
|
||||
vin: '',
|
||||
price: '',
|
||||
source_place: '',
|
||||
plate_place: '',
|
||||
plate_time: '',
|
||||
displacement: '',
|
||||
power: '',
|
||||
body_color: '',
|
||||
condition_desc: '',
|
||||
mileage: '',
|
||||
// 绑定车型数据字段
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
|
||||
// 图片和附件
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [attachments, setAttachments] = useState<Array<{ id: number; name: string; url: string; size: number }>>([])
|
||||
const [uploadingCount, setUploadingCount] = useState(0)
|
||||
|
||||
// 绑定弹窗状态
|
||||
const [bindVisible, setBindVisible] = useState(false)
|
||||
const [bindLoading, setBindLoading] = useState(false)
|
||||
const [bindResult, setBindResult] = useState<KeywordSearchResult | null>(null)
|
||||
const [bindSelected, setBindSelected] = useState<CarModel | null>(null)
|
||||
const [boundProduct, setBoundProduct] = useState<BoundProduct | null>(null)
|
||||
const [bindKeyword, setBindKeyword] = useState('')
|
||||
|
||||
// 提交中
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 上牌时间选择
|
||||
const [plateTimeVisible, setPlateTimeVisible] = useState(false)
|
||||
|
||||
// 加载详情
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
if (!id) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 响应拦截器已经返回 data.data,直接使用即可
|
||||
const detail = await usedCarApi.getDetail(Number(id)) as UsedCarDetail
|
||||
|
||||
// 解析图片 - 优先使用 images_array
|
||||
let imageUrls: string[] = []
|
||||
if (detail.images_array && detail.images_array.length > 0) {
|
||||
imageUrls = detail.images_array
|
||||
} else if (detail.images_pull && detail.images_pull.length > 0) {
|
||||
imageUrls = detail.images_pull.map(img => img.url)
|
||||
} else if (detail.images) {
|
||||
try {
|
||||
imageUrls = JSON.parse(detail.images)
|
||||
if (!Array.isArray(imageUrls)) imageUrls = []
|
||||
} catch {
|
||||
imageUrls = []
|
||||
}
|
||||
}
|
||||
|
||||
// 解析附件 - 优先使用 attachment_array
|
||||
let attachmentList: Array<{ id: number; name: string; url: string; size: number }> = []
|
||||
if (detail.attachment_array && detail.attachment_array.length > 0) {
|
||||
attachmentList = detail.attachment_array.map((item, index) => ({
|
||||
id: index,
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
size: 0
|
||||
}))
|
||||
} else if (detail.attachment_pull && detail.attachment_pull.length > 0) {
|
||||
attachmentList = detail.attachment_pull.map((item, index) => ({
|
||||
id: index,
|
||||
name: item.name || '',
|
||||
url: item.url || '',
|
||||
size: 0
|
||||
}))
|
||||
} else if (detail.attachment) {
|
||||
try {
|
||||
const parsed = JSON.parse(detail.attachment)
|
||||
if (Array.isArray(parsed)) {
|
||||
attachmentList = parsed.map((item, index) => ({
|
||||
id: index,
|
||||
name: item.name || '',
|
||||
url: item.url || '',
|
||||
size: item.size || 0
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
attachmentList = []
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({
|
||||
product_name: detail.product_name || '',
|
||||
vin: detail.vin || '',
|
||||
price: detail.price ? String(detail.price) : '',
|
||||
source_place: detail.source_place || '',
|
||||
plate_place: detail.plate_place || '',
|
||||
plate_time: detail.plate_time || '',
|
||||
displacement: detail.displacement || '',
|
||||
power: detail.power || '',
|
||||
body_color: detail.body_color || '',
|
||||
condition_desc: detail.condition_desc || '',
|
||||
mileage: detail.mileage ? String(detail.mileage) : '',
|
||||
crawl_brand: detail.crawl_brand || '',
|
||||
crawl_series: detail.crawl_series || '',
|
||||
crawl_series_id: detail.crawl_series_id || '',
|
||||
crawl_model_name: detail.crawl_model_name || '',
|
||||
crawl_model_url: detail.crawl_model_url || '',
|
||||
crawl_msrp: detail.crawl_msrp || ''
|
||||
})
|
||||
|
||||
setImages(imageUrls)
|
||||
setAttachments(attachmentList)
|
||||
|
||||
// 设置已绑定产品状态
|
||||
if (detail.crawl_brand || detail.crawl_series) {
|
||||
setBoundProduct({
|
||||
brand: detail.crawl_brand,
|
||||
series: detail.crawl_series,
|
||||
series_id: detail.crawl_series_id || '',
|
||||
model: detail.crawl_model_name ? {
|
||||
id: 0,
|
||||
name: detail.crawl_model_name,
|
||||
url: detail.crawl_model_url || '',
|
||||
msrp: detail.crawl_msrp ? Number(detail.crawl_msrp) : 0
|
||||
} : undefined
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.loadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
navigate(-1)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDetail()
|
||||
}, [id])
|
||||
|
||||
// 产品名称绑定(关键词搜索)
|
||||
const handleBindByName = async () => {
|
||||
const name = formData.product_name.trim()
|
||||
if (!name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setBindVisible(true)
|
||||
setBindLoading(true)
|
||||
setBindResult(null)
|
||||
setBindSelected(null)
|
||||
setBindKeyword('')
|
||||
|
||||
try {
|
||||
const res = await usedCarApi.crawlKeyword(name)
|
||||
setBindResult(res)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.bindFetchFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setBindLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认绑定
|
||||
const handleConfirmBind = () => {
|
||||
if (!bindSelected || !bindResult) return
|
||||
|
||||
setBoundProduct({
|
||||
brand: bindResult.brand,
|
||||
series: bindResult.series,
|
||||
series_id: bindResult.series_id,
|
||||
model: bindSelected
|
||||
})
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: bindResult.brand,
|
||||
crawl_series: bindResult.series,
|
||||
crawl_series_id: bindResult.series_id,
|
||||
crawl_model_name: bindSelected.name,
|
||||
crawl_model_url: bindSelected.url,
|
||||
crawl_msrp: bindSelected.msrp ? String(bindSelected.msrp) : ''
|
||||
})
|
||||
|
||||
setBindVisible(false)
|
||||
Toast.show({ content: t('usedCar.bindSuccess'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 清除绑定
|
||||
const handleClearBind = () => {
|
||||
setBoundProduct(null)
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
Toast.show({ content: t('usedCar.bindCleared'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 过滤车型列表
|
||||
const filteredModels = bindResult?.models.filter((model) => {
|
||||
const kw = bindKeyword.toLowerCase().trim()
|
||||
if (!kw) return true
|
||||
const name = String(model.name || '').toLowerCase()
|
||||
return name.includes(kw)
|
||||
}) || []
|
||||
|
||||
// 选择车型
|
||||
const handleSelectModel = (model: CarModel) => {
|
||||
setBindSelected(model)
|
||||
}
|
||||
|
||||
// 图片上传处理(在 ImageUploader 的 upload prop 中调用)
|
||||
const handleImageUpload = async (file: File): Promise<{ url: string }> => {
|
||||
setUploadingCount(prev => prev + 1)
|
||||
try {
|
||||
const result: any = await uploadApi.uploadUsedCarImage(file)
|
||||
return { url: result.full_url || result.url }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
} finally {
|
||||
setUploadingCount(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片变化处理
|
||||
const handleImageChange = (newFiles: ImageFileItem[]) => {
|
||||
// 更新图片列表
|
||||
setImages(newFiles.map(f => f.url))
|
||||
}
|
||||
|
||||
// 图片删除处理
|
||||
const handleImageDelete = (deletedFile: ImageFileItem) => {
|
||||
const newImages = images.filter(url => url !== deletedFile.url)
|
||||
setImages(newImages)
|
||||
}
|
||||
|
||||
// 附件上传处理
|
||||
const handleAttachmentUpload = async (file: File): Promise<{ id: number; name: string; url: string; size: number }> => {
|
||||
try {
|
||||
const result: any = await uploadApi.uploadFile(file, 'other', 'usedcar')
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.file_name || file.name,
|
||||
url: result.url,
|
||||
size: result.file_size || file.size
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 附件选择处理
|
||||
const handleAttachmentSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const newAttachments = [...attachments]
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (newAttachments.length + i >= 20) {
|
||||
Toast.show({ content: t('usedCar.maxAttachments'), icon: 'fail' })
|
||||
break
|
||||
}
|
||||
try {
|
||||
const result = await handleAttachmentUpload(file)
|
||||
newAttachments.push(result)
|
||||
} catch {
|
||||
// 上传失败已在 handleAttachmentUpload 中处理
|
||||
}
|
||||
}
|
||||
setAttachments(newAttachments)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 附件删除处理
|
||||
const handleAttachmentDelete = (index: number) => {
|
||||
const newAttachments = attachments.filter((_, i) => i !== index)
|
||||
setAttachments(newAttachments)
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.product_name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
// 检查是否有正在上传的图片
|
||||
if (uploadingCount > 0) {
|
||||
return Toast.show({ content: t('usedCar.uploading'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
id: Number(id),
|
||||
...formData,
|
||||
price: formData.price ? Number(formData.price) : undefined,
|
||||
mileage: formData.mileage ? Number(formData.mileage) : undefined,
|
||||
crawl_msrp: formData.crawl_msrp ? Number(formData.crawl_msrp) : undefined,
|
||||
// 图片数组转为逗号分隔字符串
|
||||
images: images.join(','),
|
||||
// 附件数组
|
||||
attachment: attachments,
|
||||
}
|
||||
|
||||
// 响应拦截器已经返回 data.data,直接使用即可
|
||||
await usedCarApi.update(payload)
|
||||
Toast.show({ content: t('usedCar.updateSuccess'), icon: 'success' })
|
||||
setTimeout(() => {
|
||||
navigate(-1)
|
||||
}, 1000)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('common.error')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.createPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.edit')}</NavBar>
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#999' }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.createPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.edit')}</NavBar>
|
||||
|
||||
<div className={styles.createContent}>
|
||||
{/* 产品名称卡片 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={`${styles.formCell} ${styles.formCellVertical}`}>
|
||||
<label className={styles.formLabel}>
|
||||
{t('usedCar.productName')}
|
||||
<span className={styles.required}>*</span>
|
||||
</label>
|
||||
<div className={styles.inputWithAction}>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterProductName')}
|
||||
value={formData.product_name}
|
||||
onChange={(value) => setFormData({ ...formData, product_name: value })}
|
||||
/>
|
||||
</div>
|
||||
<button className={styles.bindBtn} onClick={handleBindByName}>
|
||||
{t('usedCar.search')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 绑定状态提示 */}
|
||||
{boundProduct ? (
|
||||
<div className={styles.bindTip}>
|
||||
<span className={styles.bindTipText}>
|
||||
{t('usedCar.bindBound')}: {boundProduct.brand} · {boundProduct.series}
|
||||
{boundProduct.model && <span> · {boundProduct.model.name}</span>}
|
||||
</span>
|
||||
<span className={styles.bindTipClear} onClick={handleClearBind}>
|
||||
{t('usedCar.bindClear')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.bindTip}>
|
||||
<span>{t('usedCar.bindExample')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.price')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice')}
|
||||
value={formData.price}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, price: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.sourcePlace')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterSourcePlace')}
|
||||
value={formData.source_place}
|
||||
onChange={(value) => setFormData({ ...formData, source_place: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.plateTime')}</label>
|
||||
<div className={styles.pickerTrigger} onClick={() => setPlateTimeVisible(true)}>
|
||||
<span className={formData.plate_time ? styles.pickerValue : styles.pickerPlaceholder}>
|
||||
{formData.plate_time || t('usedCar.selectPlateTime')}
|
||||
</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={plateTimeVisible}
|
||||
onClose={() => setPlateTimeVisible(false)}
|
||||
value={formData.plate_time ? new Date(formData.plate_time) : undefined}
|
||||
onConfirm={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
setFormData({ ...formData, plate_time: dateStr })
|
||||
setPlateTimeVisible(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.displacement')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterDisplacement')}
|
||||
value={formData.displacement}
|
||||
onChange={(value) => setFormData({ ...formData, displacement: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.power')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPower')}
|
||||
value={formData.power}
|
||||
onChange={(value) => setFormData({ ...formData, power: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.mileage')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterMileage')}
|
||||
value={formData.mileage}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, mileage: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.bodyColor')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterBodyColor')}
|
||||
value={formData.body_color}
|
||||
onChange={(value) => setFormData({ ...formData, body_color: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品图 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.productImages')}</label>
|
||||
<div className={styles.imageUploadArea}>
|
||||
<ImageUploader
|
||||
value={images.map((url) => ({ url }))}
|
||||
onChange={handleImageChange}
|
||||
onDelete={handleImageDelete}
|
||||
upload={handleImageUpload}
|
||||
maxCount={20}
|
||||
multiple
|
||||
showUpload
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 车况介绍 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.conditionDesc')}</label>
|
||||
<div className={styles.admTextAreaWrapper}>
|
||||
<TextArea
|
||||
placeholder={t('usedCar.enterConditionDesc')}
|
||||
value={formData.condition_desc}
|
||||
onChange={(value) => setFormData({ ...formData, condition_desc: value })}
|
||||
rows={4}
|
||||
autoSize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 附件 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.attachments')}</label>
|
||||
<div className={styles.attachmentArea}>
|
||||
<input
|
||||
type="file"
|
||||
id="attachment-upload-edit"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleAttachmentSelect}
|
||||
/>
|
||||
<button
|
||||
className={styles.uploadBtn}
|
||||
onClick={() => document.getElementById('attachment-upload-edit')?.click()}
|
||||
disabled={attachments.length >= 20}
|
||||
>
|
||||
{t('usedCar.uploadAttachments')}
|
||||
</button>
|
||||
<div className={styles.attachmentList}>
|
||||
{attachments.map((file, index) => (
|
||||
<div key={file.id} className={styles.attachmentItem}>
|
||||
<span className={styles.attachmentName}>{file.name}</span>
|
||||
<span
|
||||
className={styles.attachmentDelete}
|
||||
onClick={() => handleAttachmentDelete(index)}
|
||||
>
|
||||
{t('usedCar.removeImage')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部提交按钮 */}
|
||||
<div className={styles.submitBar}>
|
||||
<Button
|
||||
block
|
||||
className={styles.submitBtn}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={uploadingCount > 0}
|
||||
>
|
||||
{uploadingCount > 0 ? t('usedCar.uploading') : t('usedCar.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 绑定车型弹窗 */}
|
||||
<Modal
|
||||
visible={bindVisible}
|
||||
content={
|
||||
<div className={styles.bindDialogContent}>
|
||||
{/* 弹窗头部 */}
|
||||
<div className={styles.bindDialogHeader}>
|
||||
<span className={styles.bindDialogTitle}>{t('usedCar.bindDialog')}</span>
|
||||
<span className={styles.bindDialogClose} onClick={() => setBindVisible(false)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 品牌/车系信息 */}
|
||||
{bindResult && (
|
||||
<div className={styles.bindBrandInfo}>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.brand')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.brand}</span>
|
||||
<span className={styles.bindBrandInfoSeparator}>·</span>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.series')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.series}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索框 */}
|
||||
{bindResult && bindResult.models.length > 0 && (
|
||||
<div className={styles.bindSearchArea}>
|
||||
<div className={styles.bindSearch}>
|
||||
<Input
|
||||
placeholder={t('usedCar.bindSearchPlaceholder')}
|
||||
value={bindKeyword}
|
||||
onChange={(value) => setBindKeyword(value)}
|
||||
/>
|
||||
{bindKeyword && (
|
||||
<span className={styles.bindSearchClear} onClick={() => setBindKeyword('')}>
|
||||
{t('usedCar.clear')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车型列表 */}
|
||||
<div className={styles.bindList}>
|
||||
{bindLoading ? (
|
||||
<div className={styles.bindLoading}>{t('usedCar.bindSearching')}</div>
|
||||
) : !bindResult ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindSearchFirst')}</div>
|
||||
) : bindResult.models.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoModels')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoResults')}</div>
|
||||
) : (
|
||||
filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`${styles.bindItem} ${bindSelected?.id === model.id ? styles.active : ''}`}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
>
|
||||
<div className={styles.bindItemMain}>
|
||||
<div className={styles.bindItemTitle}>{model.name}</div>
|
||||
{model.msrp > 0 && (
|
||||
<div className={styles.bindItemSub}>
|
||||
{(model.msrp / 10000).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.bindItemRadio}>
|
||||
<div className={styles.bindItemRadioDot} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className={styles.bindDialogFooter}>
|
||||
<button
|
||||
className={styles.bindCancelBtn}
|
||||
onClick={() => setBindVisible(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.bindConfirmBtn}
|
||||
disabled={!bindSelected}
|
||||
onClick={handleConfirmBind}
|
||||
>
|
||||
{t('usedCar.bindConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
closeOnMaskClick
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarEdit
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Input,
|
||||
Button,
|
||||
DatePicker,
|
||||
Toast,
|
||||
TextArea,
|
||||
ImageUploader,
|
||||
Modal
|
||||
} from 'antd-mobile'
|
||||
import { uploadApi, usedCarApi } from '@/services/api'
|
||||
import styles from './create.module.css'
|
||||
|
||||
// 车型接口
|
||||
interface CarModel {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
msrp: number
|
||||
}
|
||||
|
||||
// 关键词搜索结果接口
|
||||
interface KeywordSearchResult {
|
||||
brand: string
|
||||
series: string
|
||||
series_id: string
|
||||
models: CarModel[]
|
||||
}
|
||||
|
||||
// 绑定状态接口
|
||||
interface BoundProduct {
|
||||
brand?: string
|
||||
series?: string
|
||||
series_id?: string
|
||||
model?: CarModel
|
||||
}
|
||||
|
||||
// 图片文件项接口
|
||||
interface ImageFileItem {
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
const UsedCarCreate: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({
|
||||
product_name: '',
|
||||
vin: '',
|
||||
price: '',
|
||||
source_place: '',
|
||||
plate_place: '',
|
||||
plate_time: '',
|
||||
displacement: '',
|
||||
power: '',
|
||||
body_color: '',
|
||||
condition_desc: '',
|
||||
mileage: '',
|
||||
// 绑定车型数据字段
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
|
||||
// 图片和附件
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [attachments, setAttachments] = useState<Array<{ id: number; name: string; url: string; size: number }>>([])
|
||||
|
||||
// 绑定弹窗状态
|
||||
const [bindVisible, setBindVisible] = useState(false)
|
||||
const [bindLoading, setBindLoading] = useState(false)
|
||||
const [bindResult, setBindResult] = useState<KeywordSearchResult | null>(null)
|
||||
const [bindSelected, setBindSelected] = useState<CarModel | null>(null)
|
||||
const [boundProduct, setBoundProduct] = useState<BoundProduct | null>(null)
|
||||
const [bindKeyword, setBindKeyword] = useState('')
|
||||
|
||||
// 提交中
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 上牌时间选择
|
||||
const [plateTimeVisible, setPlateTimeVisible] = useState(false)
|
||||
|
||||
// 产品名称绑定(关键词搜索)
|
||||
const handleBindByName = async () => {
|
||||
const name = formData.product_name.trim()
|
||||
if (!name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setBindVisible(true)
|
||||
setBindLoading(true)
|
||||
setBindResult(null)
|
||||
setBindSelected(null)
|
||||
setBindKeyword('')
|
||||
|
||||
try {
|
||||
const res = await usedCarApi.crawlKeyword(name)
|
||||
setBindResult(res)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.bindFetchFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setBindLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认绑定
|
||||
const handleConfirmBind = () => {
|
||||
if (!bindSelected || !bindResult) return
|
||||
|
||||
setBoundProduct({
|
||||
brand: bindResult.brand,
|
||||
series: bindResult.series,
|
||||
series_id: bindResult.series_id,
|
||||
model: bindSelected
|
||||
})
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: bindResult.brand,
|
||||
crawl_series: bindResult.series,
|
||||
crawl_series_id: bindResult.series_id,
|
||||
crawl_model_name: bindSelected.name,
|
||||
crawl_model_url: bindSelected.url,
|
||||
crawl_msrp: bindSelected.msrp ? String(bindSelected.msrp) : ''
|
||||
})
|
||||
|
||||
setBindVisible(false)
|
||||
Toast.show({ content: t('usedCar.bindSuccess'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 清除绑定
|
||||
const handleClearBind = () => {
|
||||
setBoundProduct(null)
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
Toast.show({ content: t('usedCar.bindCleared'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 过滤车型列表
|
||||
const filteredModels = bindResult?.models.filter((model) => {
|
||||
const kw = bindKeyword.toLowerCase().trim()
|
||||
if (!kw) return true
|
||||
const name = String(model.name || '').toLowerCase()
|
||||
return name.includes(kw)
|
||||
}) || []
|
||||
|
||||
// 选择车型
|
||||
const handleSelectModel = (model: CarModel) => {
|
||||
setBindSelected(model)
|
||||
}
|
||||
|
||||
// 跟踪上传数量
|
||||
const [uploadingCount, setUploadingCount] = useState(0)
|
||||
|
||||
// 图片上传处理(在 ImageUploader 的 upload prop 中调用)
|
||||
const handleImageUpload = async (file: File): Promise<{ url: string }> => {
|
||||
setUploadingCount(prev => prev + 1)
|
||||
try {
|
||||
const result: any = await uploadApi.uploadUsedCarImage(file)
|
||||
return { url: result.full_url || result.url }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
} finally {
|
||||
setUploadingCount(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片变化处理
|
||||
const handleImageChange = (newFiles: ImageFileItem[]) => {
|
||||
// 更新图片列表
|
||||
setImages(newFiles.map(f => f.url))
|
||||
}
|
||||
|
||||
// 图片删除处理
|
||||
const handleImageDelete = (deletedFile: ImageFileItem) => {
|
||||
const newImages = images.filter(url => url !== deletedFile.url)
|
||||
setImages(newImages)
|
||||
}
|
||||
|
||||
// 附件上传处理
|
||||
const handleAttachmentUpload = async (file: File): Promise<{ id: number; name: string; url: string; size: number }> => {
|
||||
try {
|
||||
const result: any = await uploadApi.uploadFile(file, 'other', 'usedcar')
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.file_name || file.name,
|
||||
url: result.url,
|
||||
size: result.file_size || file.size
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 附件选择处理
|
||||
const handleAttachmentSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const newAttachments = [...attachments]
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (newAttachments.length + i >= 20) {
|
||||
Toast.show({ content: t('usedCar.maxAttachments'), icon: 'fail' })
|
||||
break
|
||||
}
|
||||
try {
|
||||
const result = await handleAttachmentUpload(file)
|
||||
newAttachments.push(result)
|
||||
} catch (err) {
|
||||
// 上传失败已在 handleAttachmentUpload 中处理
|
||||
}
|
||||
}
|
||||
setAttachments(newAttachments)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 附件删除处理
|
||||
const handleAttachmentDelete = (index: number) => {
|
||||
const newAttachments = attachments.filter((_, i) => i !== index)
|
||||
setAttachments(newAttachments)
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.product_name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
// 检查是否有正在上传的图片
|
||||
if (uploadingCount > 0) {
|
||||
return Toast.show({ content: t('usedCar.uploading'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
price: formData.price ? Number(formData.price) : undefined,
|
||||
mileage: formData.mileage ? Number(formData.mileage) : undefined,
|
||||
crawl_msrp: formData.crawl_msrp ? Number(formData.crawl_msrp) : undefined,
|
||||
// 图片数组转为逗号分隔字符串
|
||||
images: images.join(','),
|
||||
// 附件数组转为JSON
|
||||
attachment: attachments,
|
||||
}
|
||||
|
||||
const id = await usedCarApi.create(payload)
|
||||
if (id) {
|
||||
Toast.show({ content: t('usedCar.createSuccess'), icon: 'success' })
|
||||
setTimeout(() => {
|
||||
navigate('/used-car')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('common.error')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.createPage}>
|
||||
{/* <NavBar onBack={() => navigate(-1)}>{t('usedCar.pageTitle')}</NavBar> */}
|
||||
|
||||
<div className={styles.createContent}>
|
||||
{/* 产品名称卡片 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={`${styles.formCell} ${styles.formCellVertical}`}>
|
||||
<label className={styles.formLabel}>
|
||||
{t('usedCar.productName')}
|
||||
<span className={styles.required}>*</span>
|
||||
</label>
|
||||
<div className={styles.inputWithAction}>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterProductName')}
|
||||
value={formData.product_name}
|
||||
onChange={(value) => setFormData({ ...formData, product_name: value })}
|
||||
/>
|
||||
</div>
|
||||
<button className={styles.bindBtn} onClick={handleBindByName}>
|
||||
{t('usedCar.search')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 绑定状态提示 */}
|
||||
{boundProduct ? (
|
||||
<div className={styles.bindTip}>
|
||||
<span className={styles.bindTipText}>
|
||||
{t('usedCar.bindBound')}: {boundProduct.brand} · {boundProduct.series}
|
||||
{boundProduct.model && <span> · {boundProduct.model.name}</span>}
|
||||
</span>
|
||||
<span className={styles.bindTipClear} onClick={handleClearBind}>
|
||||
{t('usedCar.bindClear')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.bindTip}>
|
||||
<span>{t('usedCar.bindExample')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.price')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice')}
|
||||
value={formData.price}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, price: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.sourcePlace')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterSourcePlace')}
|
||||
value={formData.source_place}
|
||||
onChange={(value) => setFormData({ ...formData, source_place: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.plateTime')}</label>
|
||||
<div className={styles.pickerTrigger} onClick={() => setPlateTimeVisible(true)}>
|
||||
<span className={formData.plate_time ? styles.pickerValue : styles.pickerPlaceholder}>
|
||||
{formData.plate_time || t('usedCar.selectPlateTime')}
|
||||
</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={plateTimeVisible}
|
||||
onClose={() => setPlateTimeVisible(false)}
|
||||
onConfirm={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
setFormData({ ...formData, plate_time: dateStr })
|
||||
setPlateTimeVisible(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.displacement')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterDisplacement')}
|
||||
value={formData.displacement}
|
||||
onChange={(value) => setFormData({ ...formData, displacement: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.power')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPower')}
|
||||
value={formData.power}
|
||||
onChange={(value) => setFormData({ ...formData, power: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.mileage')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterMileage')}
|
||||
value={formData.mileage}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, mileage: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.bodyColor')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterBodyColor')}
|
||||
value={formData.body_color}
|
||||
onChange={(value) => setFormData({ ...formData, body_color: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品图 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.productImages')}</label>
|
||||
<div className={styles.imageUploadArea}>
|
||||
<ImageUploader
|
||||
value={images.map((url) => ({ url }))}
|
||||
onChange={handleImageChange}
|
||||
onDelete={handleImageDelete}
|
||||
upload={handleImageUpload}
|
||||
maxCount={20}
|
||||
multiple
|
||||
showUpload
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 附件 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.attachments')}</label>
|
||||
<div className={styles.attachmentArea}>
|
||||
<input
|
||||
type="file"
|
||||
id="attachment-upload"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleAttachmentSelect}
|
||||
/>
|
||||
<button
|
||||
className={styles.uploadBtn}
|
||||
onClick={() => document.getElementById('attachment-upload')?.click()}
|
||||
disabled={attachments.length >= 20}
|
||||
>
|
||||
{t('usedCar.uploadAttachments')}
|
||||
</button>
|
||||
<div className={styles.attachmentList}>
|
||||
{attachments.map((file, index) => (
|
||||
<div key={file.id} className={styles.attachmentItem}>
|
||||
<span className={styles.attachmentName}>{file.name}</span>
|
||||
<span
|
||||
className={styles.attachmentDelete}
|
||||
onClick={() => handleAttachmentDelete(index)}
|
||||
>
|
||||
{t('usedCar.removeImage')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 车况介绍 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.conditionDesc')}</label>
|
||||
<div className={styles.admTextAreaWrapper}>
|
||||
<TextArea
|
||||
placeholder={t('usedCar.enterConditionDesc')}
|
||||
value={formData.condition_desc}
|
||||
onChange={(value) => setFormData({ ...formData, condition_desc: value })}
|
||||
rows={4}
|
||||
autoSize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部提交按钮 */}
|
||||
<div className={styles.submitBar}>
|
||||
<Button
|
||||
block
|
||||
className={styles.submitBtn}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={uploadingCount > 0}
|
||||
>
|
||||
{uploadingCount > 0 ? t('usedCar.uploading') : t('usedCar.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 绑定车型弹窗 */}
|
||||
<Modal
|
||||
visible={bindVisible}
|
||||
content={
|
||||
<div className={styles.bindDialogContent}>
|
||||
{/* 弹窗头部 */}
|
||||
<div className={styles.bindDialogHeader}>
|
||||
<span className={styles.bindDialogTitle}>{t('usedCar.bindDialog')}</span>
|
||||
<span className={styles.bindDialogClose} onClick={() => setBindVisible(false)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 品牌/车系信息 */}
|
||||
{bindResult && (
|
||||
<div className={styles.bindBrandInfo}>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.brand')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.brand}</span>
|
||||
<span className={styles.bindBrandInfoSeparator}>·</span>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.series')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.series}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索框 */}
|
||||
{bindResult && bindResult.models.length > 0 && (
|
||||
<div className={styles.bindSearchArea}>
|
||||
<div className={styles.bindSearch}>
|
||||
<Input
|
||||
placeholder={t('usedCar.bindSearchPlaceholder')}
|
||||
value={bindKeyword}
|
||||
onChange={(value) => setBindKeyword(value)}
|
||||
/>
|
||||
{bindKeyword && (
|
||||
<span className={styles.bindSearchClear} onClick={() => setBindKeyword('')}>
|
||||
{t('usedCar.clear')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车型列表 */}
|
||||
<div className={styles.bindList}>
|
||||
{bindLoading ? (
|
||||
<div className={styles.bindLoading}>{t('usedCar.bindSearching')}</div>
|
||||
) : !bindResult ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindSearchFirst')}</div>
|
||||
) : bindResult.models.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoModels')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoResults')}</div>
|
||||
) : (
|
||||
filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`${styles.bindItem} ${bindSelected?.id === model.id ? styles.active : ''}`}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
>
|
||||
<div className={styles.bindItemMain}>
|
||||
<div className={styles.bindItemTitle}>{model.name}</div>
|
||||
{model.msrp > 0 && (
|
||||
<div className={styles.bindItemSub}>
|
||||
{(model.msrp / 10000).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.bindItemRadio}>
|
||||
<div className={styles.bindItemRadioDot} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className={styles.bindDialogFooter}>
|
||||
<button
|
||||
className={styles.bindCancelBtn}
|
||||
onClick={() => setBindVisible(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.bindConfirmBtn}
|
||||
disabled={!bindSelected}
|
||||
onClick={handleConfirmBind}
|
||||
>
|
||||
{t('usedCar.bindConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
closeOnMaskClick
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarCreate
|
||||
@@ -0,0 +1,39 @@
|
||||
.usedCarListPage {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f8;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 搜索和筛选栏 */
|
||||
.searchFilterBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 使用 :global 包裹第三方组件类名 */
|
||||
.searchFilterBar :global(.adm-search-bar) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 列表容器 - 减少左右间距 */
|
||||
.listContainer {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
/* 覆盖 InfiniteScroll 默认样式 - 只影响当前页面 */
|
||||
.listContainer :global(.adm-infinite-scroll) {
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.listContainer :global(.adm-infinite-scroll-content) {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, SearchBar, Dropdown, Toast, InfiniteScroll, Empty } from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import CarCard from '@/components/CarCard'
|
||||
import { UsedCarItem } from '@/components/CarCard'
|
||||
import PriceModal from '@/components/PriceModal'
|
||||
import type { PriceFormData } from '@/components/PriceModal'
|
||||
import styles from './list.module.css'
|
||||
|
||||
// 二手车状态
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: '全部', value: 0 },
|
||||
{ label: '正常', value: 1 },
|
||||
{ label: '归档', value: 2 },
|
||||
]
|
||||
|
||||
const UsedCarList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [list, setList] = useState<UsedCarItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState<number>(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusVisible, setStatusVisible] = useState(false)
|
||||
|
||||
// 价格弹框相关状态
|
||||
const [priceModalVisible, setPriceModalVisible] = useState(false)
|
||||
const [currentItem, setCurrentItem] = useState<UsedCarItem | null>(null)
|
||||
|
||||
// 加载数据
|
||||
const loadMore = async (isRefresh = false) => {
|
||||
if (loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const currentPage = isRefresh ? 1 : page
|
||||
// 响应拦截器已经返回 data 部分
|
||||
const res = await usedCarApi.getList({
|
||||
type: 'all',
|
||||
page: currentPage,
|
||||
page_size: 20,
|
||||
keyword: keyword || undefined,
|
||||
status: status !== 0 ? status : undefined,
|
||||
})
|
||||
|
||||
// res 已经是 { list, total, page, page_size }
|
||||
const newList = res.list || []
|
||||
if (isRefresh) {
|
||||
setList(newList)
|
||||
setPage(2)
|
||||
} else {
|
||||
setList([...list, ...newList])
|
||||
setPage(currentPage + 1)
|
||||
}
|
||||
setHasMore(newList.length >= 20)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.loadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value)
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
const handleStatusChange = (value: number) => {
|
||||
setStatus(value)
|
||||
setStatusVisible(false)
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
const handleRefresh = () => {
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 请求价格(点击下载 PDF 但没有 PDF URL 时触发)
|
||||
const handleRequestPrice = ({ item }: { item: UsedCarItem; index: number }) => {
|
||||
setCurrentItem(item)
|
||||
setPriceModalVisible(true)
|
||||
}
|
||||
|
||||
// 确认价格并生成 PDF
|
||||
const handlePriceConfirm = async (data: PriceFormData) => {
|
||||
if (!currentItem) return
|
||||
|
||||
// 通过全局方法调用 CarCard 的 PDF 生成函数
|
||||
const generateFunc = (window as { carCardDownloadPdf?: (data: PriceFormData) => Promise<void> }).carCardDownloadPdf
|
||||
if (generateFunc && typeof generateFunc === 'function') {
|
||||
await generateFunc(data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadMore(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.usedCarListPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.title')}</NavBar>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className={styles.searchFilterBar}>
|
||||
<SearchBar
|
||||
placeholder={t('usedCar.searchPlaceholder')}
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Dropdown
|
||||
visible={statusVisible}
|
||||
onClose={() => setStatusVisible(false)}
|
||||
trigger="click"
|
||||
>
|
||||
<Dropdown.Item key="status" title={STATUS_OPTIONS.find(s => s.value === status)?.label || t('usedCar.status')}>
|
||||
<div style={{ padding: '12px' }}>
|
||||
{STATUS_OPTIONS.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleStatusChange(option.value)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: status === option.value ? '#f5f5f5' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className={styles.listContainer}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description={t('usedCar.empty')} />
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
loadMore={loadMore}
|
||||
hasMore={hasMore}
|
||||
>
|
||||
{list.map((item, index) => (
|
||||
<CarCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onRefresh={handleRefresh}
|
||||
onRequestPrice={handleRequestPrice}
|
||||
/>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 价格输入弹框 */}
|
||||
<PriceModal
|
||||
visible={priceModalVisible}
|
||||
onClose={() => setPriceModalVisible(false)}
|
||||
onConfirm={handlePriceConfirm}
|
||||
item={currentItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarList
|
||||
@@ -0,0 +1,131 @@
|
||||
.mine {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* User Info Card */
|
||||
.userCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #3F435B;
|
||||
margin: 12px;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(63, 67, 91, 0.2);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarText {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.userNameEn {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.userPhone {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.userArrow {
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Menu */
|
||||
.menu {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
margin: 0 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menuItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menuItem:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.menuLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.menuLabel {
|
||||
font-size: 15px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.menuArrow {
|
||||
font-size: 16px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
/* Bottom Safe Area */
|
||||
.safeArea {
|
||||
height: 20px;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, Toast } from 'antd-mobile'
|
||||
import { userApi } from '@/services/api'
|
||||
import styles from './mine.module.css'
|
||||
|
||||
interface MenuItem {
|
||||
id: string
|
||||
icon: string
|
||||
label: string
|
||||
path?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: number
|
||||
name: string
|
||||
name_en?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
role: number // 1=Admin, 2=User
|
||||
company_id?: number
|
||||
}
|
||||
|
||||
export default function UsedCarMine() {
|
||||
const navigate = useNavigate()
|
||||
const { user: storeUser } = useUserStore()
|
||||
const { t } = useTranslation()
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo()
|
||||
}, [])
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await userApi.getInfo()
|
||||
setUserInfo(data)
|
||||
} catch (error: any) {
|
||||
Toast.show({ content: error.message || t('common.error') })
|
||||
// If API fails, use store user data
|
||||
setUserInfo({
|
||||
id: storeUser?.id || 0,
|
||||
name: storeUser?.name || '',
|
||||
phone: storeUser?.phone,
|
||||
email: storeUser?.email,
|
||||
avatar: storeUser?.avatar,
|
||||
role: 2, // Default to normal user
|
||||
company_id: storeUser?.company_id,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'my-publish',
|
||||
icon: '🚗',
|
||||
label: userInfo?.role === 1 ? t('usedCar.myGarage') : t('usedCar.myPublish'),
|
||||
path: '/used-car/my-list',
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
icon: '👤',
|
||||
label: t('usedCar.personalInfo'),
|
||||
onClick: () => navigate('/used-car/profile'),
|
||||
},
|
||||
]
|
||||
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
if (item.path) {
|
||||
navigate(item.path)
|
||||
} else if (item.onClick) {
|
||||
item.onClick()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserCardClick = () => {
|
||||
navigate('/used-car/profile')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.mine}>
|
||||
<NavBar back={null}>{t('usedCar.myPage')}</NavBar>
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.mine}>
|
||||
<NavBar back={null}>{t('usedCar.myPage')}</NavBar>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className={styles.userCard} onClick={handleUserCardClick}>
|
||||
<div className={styles.avatar}>
|
||||
{userInfo?.avatar ? (
|
||||
<img src={userInfo.avatar} alt={userInfo.name} className={styles.avatarImg} />
|
||||
) : (
|
||||
<span className={styles.avatarText}>{userInfo?.name?.[0] || 'U'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.userName}>{userInfo?.name || t('login.username')}</div>
|
||||
{userInfo?.name_en && <div className={styles.userNameEn}>{userInfo.name_en}</div>}
|
||||
{userInfo?.phone && <div className={styles.userPhone}>{userInfo.phone}</div>}
|
||||
</div>
|
||||
<div className={styles.userArrow}>›</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className={styles.menu}>
|
||||
{menuItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={styles.menuItem}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
>
|
||||
<div className={styles.menuLeft}>
|
||||
<span className={styles.menuIcon}>{item.icon}</span>
|
||||
<span className={styles.menuLabel}>{item.label}</span>
|
||||
</div>
|
||||
<span className={styles.menuArrow}>›</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Safe Area */}
|
||||
<div className={styles.safeArea} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, SearchBar, Dropdown, Toast, InfiniteScroll, Empty } from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import CarCard from '@/components/CarCard'
|
||||
import { UsedCarItem } from '@/components/CarCard'
|
||||
import PriceModal from '@/components/PriceModal'
|
||||
import type { PriceFormData } from '@/components/PriceModal'
|
||||
import styles from './list.module.css'
|
||||
|
||||
const MyPublishList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 二手车状态
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: t('common.all'), value: 0 },
|
||||
{ label: t('usedCar.statusNormal'), value: 1 },
|
||||
{ label: t('usedCar.statusArchived'), value: 2 },
|
||||
{ label: t('usedCar.statusSold'), value: 4 },
|
||||
]
|
||||
|
||||
const [list, setList] = useState<UsedCarItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState<number>(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusVisible, setStatusVisible] = useState(false)
|
||||
|
||||
// 价格弹框相关状态
|
||||
const [priceModalVisible, setPriceModalVisible] = useState(false)
|
||||
const [currentItem, setCurrentItem] = useState<UsedCarItem | null>(null)
|
||||
|
||||
// 加载数据
|
||||
const loadMore = async (isRefresh = false) => {
|
||||
if (loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const currentPage = isRefresh ? 1 : page
|
||||
// 响应拦截器已经返回 data 部分
|
||||
const res = await usedCarApi.getList({
|
||||
type: 'self', // 只获取当前用户的数据
|
||||
page: currentPage,
|
||||
page_size: 20,
|
||||
keyword: keyword || undefined,
|
||||
status: status !== 0 ? status : undefined,
|
||||
})
|
||||
|
||||
// res 已经是 { list, total, page, page_size }
|
||||
const newList = res.list || []
|
||||
if (isRefresh) {
|
||||
setList(newList)
|
||||
setPage(2)
|
||||
} else {
|
||||
setList([...list, ...newList])
|
||||
setPage(currentPage + 1)
|
||||
}
|
||||
setHasMore(newList.length >= 20)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.loadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value)
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
const handleStatusChange = (value: number) => {
|
||||
setStatus(value)
|
||||
setStatusVisible(false)
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
const handleRefresh = () => {
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 请求价格(点击下载 PDF 但没有 PDF URL 时触发)
|
||||
const handleRequestPrice = ({ item }: { item: UsedCarItem; index: number }) => {
|
||||
setCurrentItem(item)
|
||||
setPriceModalVisible(true)
|
||||
}
|
||||
|
||||
// 确认价格并生成 PDF
|
||||
const handlePriceConfirm = async (data: PriceFormData) => {
|
||||
if (!currentItem) return
|
||||
|
||||
// 通过全局方法调用 CarCard 的 PDF 生成函数
|
||||
const generateFunc = (window as { carCardDownloadPdf?: (data: PriceFormData) => Promise<void> }).carCardDownloadPdf
|
||||
if (generateFunc && typeof generateFunc === 'function') {
|
||||
await generateFunc(data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadMore(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.usedCarListPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.myPublish')}</NavBar>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className={styles.searchFilterBar}>
|
||||
<SearchBar
|
||||
placeholder={t('usedCar.searchPlaceholder')}
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Dropdown
|
||||
visible={statusVisible}
|
||||
onClose={() => setStatusVisible(false)}
|
||||
trigger="click"
|
||||
>
|
||||
<Dropdown.Item key="status" title={STATUS_OPTIONS.find(s => s.value === status)?.label || t('usedCar.status')}>
|
||||
<div style={{ padding: '12px' }}>
|
||||
{STATUS_OPTIONS.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleStatusChange(option.value)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: status === option.value ? '#f5f5f5' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className={styles.listContainer}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description={t('usedCar.emptyMyPublish')} />
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
loadMore={loadMore}
|
||||
hasMore={hasMore}
|
||||
>
|
||||
{list.map((item, index) => (
|
||||
<CarCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isMy={true}
|
||||
index={index}
|
||||
onRefresh={handleRefresh}
|
||||
onRequestPrice={handleRequestPrice}
|
||||
/>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 价格输入弹框 */}
|
||||
<PriceModal
|
||||
visible={priceModalVisible}
|
||||
onClose={() => setPriceModalVisible(false)}
|
||||
onConfirm={handlePriceConfirm}
|
||||
item={currentItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyPublishList
|
||||
@@ -18,6 +18,13 @@ import SimpleLogin from '@/pages/login-simple'
|
||||
import DebugFeishu from '@/pages/debug-feishu'
|
||||
import FeishuEnvTest from '@/pages/feishu-env-test'
|
||||
|
||||
import UsedCarList from '@/pages/used-car/list'
|
||||
import UsedCarDetail from '@/pages/used-car/detail'
|
||||
import UsedCarCreate from '@/pages/used-car/create'
|
||||
import UsedCarEdit from '@/pages/used-car/edit'
|
||||
import UsedCarMine from '@/pages/used-car/mine'
|
||||
import UsedCarMyList from '@/pages/used-car/my-list'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
@@ -36,9 +43,15 @@ const router = createBrowserRouter([
|
||||
{ path: 'business-trip/:id', element: <BusinessTripDetail /> },
|
||||
{ path: 'approval', element: <ApprovalList /> },
|
||||
{ path: 'approval/:id', element: <ApprovalDetail /> },
|
||||
{ path: 'used-car', element: <UsedCarList /> },
|
||||
{ path: 'used-car/mine', element: <UsedCarMine /> },
|
||||
{ path: 'used-car/my-list', element: <UsedCarMyList /> },
|
||||
{ path: 'used-car/create', element: <UsedCarCreate /> },
|
||||
{ path: 'used-car/edit/:id', element: <UsedCarEdit /> },
|
||||
{ path: 'used-car/detail/:id', element: <UsedCarDetail /> },
|
||||
],
|
||||
},
|
||||
// 详情页移到顶层,支持未登录访问(页面自己处理登录检查)
|
||||
// 详情页移到顶层,支持未登录访问(页面自己处理登录检查)
|
||||
{ path: '/approval/detail/:id', element: <ApprovalDetail /> },
|
||||
{ path: '/business-trip/detail/:id', element: <BusinessTripDetail /> },
|
||||
{ path: '/login', element: <Login /> },
|
||||
|
||||
@@ -261,7 +261,8 @@ api.interceptors.request.use(
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
const { data } = response
|
||||
if (data.code === 0) {
|
||||
// 后端成功返回 code === 200
|
||||
if (data.code === 200 || data.code === 0) {
|
||||
return data.data
|
||||
}
|
||||
return Promise.reject(new Error(data.message || '请求失败'))
|
||||
@@ -401,4 +402,138 @@ export const approvalApi = {
|
||||
cancel: (id: number) => api.post(`/approval/cancel/${id}`),
|
||||
}
|
||||
|
||||
|
||||
// ========== 二手车 API ==========
|
||||
export const usedCarApi = {
|
||||
// 获取二手车列表
|
||||
getList: (params: {
|
||||
type?: 'self' | 'all'
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
status?: number
|
||||
}) => api.get<any, any>('/usedcar/list', { params }),
|
||||
|
||||
// 获取二手车详情
|
||||
getDetail: (id: number) => api.get<any, any>('/usedcar/detail', { params: { id } }),
|
||||
|
||||
// 创建二手车
|
||||
create: (data: any) => api.post<any, { id: number }>('/usedcar/create', data),
|
||||
|
||||
// 更新二手车
|
||||
update: (data: any) => api.post<any, any>('/usedcar/update', data),
|
||||
|
||||
// 删除二手车
|
||||
delete: (id: number) => api.post<any, any>('/usedcar/delete', { id }),
|
||||
|
||||
// 更新状态
|
||||
updateStatus: (id: number, status: number) =>
|
||||
api.post<any, any>('/usedcar/status', { id, status }),
|
||||
|
||||
// 爬取URL
|
||||
crawlByUrl: (url: string) => api.post<any, any>('/usedcar/crawl/url', { url }),
|
||||
|
||||
// 爬取移动端URL
|
||||
crawlMobile: (url: string) => api.post<any, any>('/usedcar/crawl/mobile', { url }),
|
||||
|
||||
// 关键词搜索车型
|
||||
crawlKeyword: (keyword: string) => api.post<any, {
|
||||
brand: string
|
||||
series: string
|
||||
series_id: string
|
||||
models: Array<{
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
msrp: number
|
||||
}>
|
||||
}>('/usedcar/crawl/keyword', { keyword }),
|
||||
|
||||
// 生成PDF
|
||||
generatePdf: (params: {
|
||||
id: number
|
||||
trade_term?: string
|
||||
price?: number
|
||||
currency?: string
|
||||
lang?: string
|
||||
}) => api.post<any, { pdf_url: string; file_name: string }>('/usedcar/pdf/generate', params),
|
||||
|
||||
// 下载PDF
|
||||
downloadPdf: (id: number) => api.get<any, any>('/usedcar/pdf/download', { params: { id } }),
|
||||
|
||||
// 获取PDF URL
|
||||
getPdfUrl: (id: number) => api.get<any, { pdf_url: string; file_name: string }>('/usedcar/pdf/url', { params: { id } }),
|
||||
|
||||
// 预览PDF
|
||||
previewPdf: (id: number) => api.get<any, any>('/usedcar/pdf/preview', { params: { id } }),
|
||||
}
|
||||
|
||||
// ========== 文件上传 API ==========
|
||||
export const uploadApi = {
|
||||
// 上传二手车图片
|
||||
uploadUsedCarImage: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post<any, any>('/upload/usedcar', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}).then((res) => {
|
||||
// 后端返回 file_url(相对路径)和 full_url(完整URL)
|
||||
// 使用 file_url 因为它在开发和生产环境都能正确访问
|
||||
console.log('上传响应:', res)
|
||||
return {
|
||||
...res,
|
||||
url: res.file_url || res.full_url || res.url
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 批量上传图片
|
||||
batchUploadImages: (files: File[]) => {
|
||||
const formData = new FormData()
|
||||
files.forEach(file => {
|
||||
formData.append('files[]', file)
|
||||
})
|
||||
return api.post<any, any>('/upload/batch', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// 通用图片上传
|
||||
uploadImage: (file: File, category = 'general') => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('category', category)
|
||||
return api.post<any, any>('/upload/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// 删除文件
|
||||
deleteFile: (fileId: number) => api.post<any, any>('/upload/delete', { id: fileId }),
|
||||
|
||||
// 通用文件上传(支持附件,如 PDF、Word 等)
|
||||
uploadFile: (file: File, partition = 'document', category = 'general') => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('partition', partition)
|
||||
formData.append('category', category)
|
||||
return api.post<any, any>('/upload/file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}).then((res) => {
|
||||
return {
|
||||
...res,
|
||||
url: res.file_url || res.full_url || res.url
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
export default api
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export default defineConfig({
|
||||
target: 'http://127.0.0.1:8787',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://127.0.0.1:8787',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user