3 Commits

Author SHA1 Message Date
浅念 1fff0a7563 添加 备货单模块和用户系统映射 2026-03-05 13:25:35 +08:00
18338781771 0da3969bd6 用户映射关系 2026-03-03 15:07:13 +08:00
18338781771 ae9b82e683 获取fdesk对应的用户id 2026-03-03 14:50:41 +08:00
42 changed files with 3596 additions and 5161 deletions
-11
View File
@@ -21,14 +21,3 @@ JWT_SECRET=your-secret-key-change-in-production
# 飞书配置
FEISHU_APP_ID=
FEISHU_APP_SECRET=
# 阿里云 OSS 配置
OSS_ACCESSID=LTAI5tAGiH7bkVkmrniXnfkA
OSS_ACCESSSECRET=nqklJBft6t3S0A4ZvxF4ci9ni91kmm
OSS_BUCKET=vehicle-album
OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
OSS_STATIC_URL=https://vehicle-album.oss-cn-beijing.aliyuncs.com
OSS_MAIN_DIR=x_server_mobile
# 域名配置(用于生成文件访问URL)
MAIN_DOMAIN=http://localhost:8787
-135
View File
@@ -609,139 +609,4 @@ HTML;
return $this->error($e->getMessage());
}
}
/**
* 获取飞书 JS API 鉴权签名
* 用于前端初始化飞书 JS SDK
*
* company 参数可选,如果未提供则从登录 token 中获取用户所属公司
*/
public function feishuJsapiSignature(Request $request): Response
{
$url = $request->get('url');
$companyParam = $request->get('company');
if (!$url) {
return $this->error('缺少 url 参数');
}
try {
$companyId = null;
// 优先使用传入的 company 参数
if ($companyParam) {
$company = \app\model\Company::find($companyParam);
if (!$company) {
return $this->error('公司不存在');
}
$companyId = $company->id;
error_log("✅ 使用参数中的 company: {$companyId}");
}
// 从 token 中获取用户所属公司
else {
$companyId = $this->getCompanyId($request);
if (!$companyId) {
return $this->error('未登录且未指定公司', 401);
}
error_log("✅ 从 token 获取 company: {$companyId}");
}
// 获取该公司的默认飞书应用
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId)
->where('system_id', 1)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('该公司未配置飞书应用');
}
// 生成 JS API 签名
$signatureData = $this->generateJsapiSignature($feishuApp->id, $url);
return $this->success([
'app_id' => $feishuApp->app_id,
'company_id' => $companyId,
'jsapi_ticket' => $signatureData['jsapi_ticket'],
'noncestr' => $signatureData['noncestr'],
'timestamp' => $signatureData['timestamp'],
'url' => $signatureData['url'],
'signature' => $signatureData['signature'],
]);
} catch (\Exception $e) {
error_log("❌ feishuJsapiSignature 失败: " . $e->getMessage());
return $this->error($e->getMessage());
}
}
/**
* 生成 JS API 鉴权签名
*
* @param int $feishuAppId 飞书应用配置ID
* @param string $url 当前页面URL
* @return array 签名数据
*/
private function generateJsapiSignature(int $feishuAppId, string $url): array
{
// 获取 jsapi_ticket
$jsapiTicket = \app\util\FeishuUtil::getJsapiTicket($feishuAppId);
// 清理 URL:移除 # 和 ? 后面的参数
$url = $this->cleanUrl($url);
// 生成随机字符串
$noncestr = $this->generateNonceStr();
// 获取毫秒级时间戳
$timestamp = (int)round(microtime(true) * 1000);
// 拼接签名字符串
$verifyStr = "jsapi_ticket={$jsapiTicket}&noncestr={$noncestr}&timestamp={$timestamp}&url={$url}";
// 生成 SHA1 签名
$signature = sha1($verifyStr);
error_log("✅ JS API 签名生成成功: url={$url}");
return [
'jsapi_ticket' => $jsapiTicket,
'noncestr' => $noncestr,
'timestamp' => $timestamp,
'url' => $url,
'signature' => $signature,
];
}
/**
* 清理 URL,移除 # 和 ? 后面的参数
*/
private function cleanUrl(string $url): string
{
// 移除 # 后面的内容
$pos = strpos($url, '#');
if ($pos !== false) {
$url = substr($url, 0, $pos);
}
// 移除 ? 后面的内容
$pos = strpos($url, '?');
if ($pos !== false) {
$url = substr($url, 0, $pos);
}
return $url;
}
/**
* 生成随机字符串(由数字、字母组成)
*/
private function generateNonceStr(int $length = 16): string
{
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$noncestr = '';
for ($i = 0; $i < $length; $i++) {
$noncestr .= $chars[mt_rand(0, strlen($chars) - 1)];
}
return $noncestr;
}
}
+1 -23
View File
@@ -52,7 +52,7 @@ abstract class BaseController
}
// 解析 token
$payload = \app\Util\JwtUtil::decode($token);
$payload = app\util\JwtUtil::decode($token);
if (!$payload) {
return null;
}
@@ -71,26 +71,4 @@ abstract class BaseController
}
return $userId;
}
/**
* 获取当前用户的公司ID
*/
protected function getCompanyId(Request $request): ?int
{
$token = $request->header('Authorization');
if ($token) {
$token = str_replace('Bearer ', '', $token);
}
if (!$token) {
return null;
}
$payload = \app\Util\JwtUtil::decode($token);
if (!$payload) {
return null;
}
return (int) ($payload['company_id'] ?? null);
}
}
-173
View File
@@ -1,173 +0,0 @@
<?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());
}
}
}
@@ -1,378 +0,0 @@
<?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;
}
}
+33
View File
@@ -3,6 +3,7 @@
namespace app\controller;
use app\model\User;
use app\model\UserSystemMapping;
use Webman\Http\Request;
use Webman\Http\Response;
@@ -130,4 +131,36 @@ class UserController extends BaseController
'page_size' => $pageSize,
]);
}
/**
* 获取用户在指定系统的映射ID
*/
public function systemMapping(Request $request): Response
{
$userId = $this->getUserId($request);
$systemCode = $request->get('system', 'fdesk');
// 获取用户信息以获取公司ID
$user = User::find($userId);
if (!$user) {
return $this->error('用户不存在');
}
// 查询系统映射
$mapping = UserSystemMapping::active()
->bySystem($systemCode)
->where('user_id', $userId)
->where('company_id', $user->company_id)
->first();
if (!$mapping) {
return $this->error('未找到系统映射信息,请联系管理员配置');
}
return $this->success([
'system_code' => $mapping->system_code,
'system_user_id' => $mapping->system_user_id,
'system_user_name' => $mapping->system_user_name,
]);
}
}
-115
View File
@@ -1,115 +0,0 @@
<?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;
}
}
-143
View File
@@ -1,143 +0,0 @@
<?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}%");
});
}
}
-58
View File
@@ -1,58 +0,0 @@
<?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);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace app\model;
use support\Model;
class UserSystemMapping extends Model
{
protected $table = 'user_system_mapping';
/**
* 可填充字段
*/
protected $fillable = [
'user_id',
'company_id',
'system_code',
'system_user_id',
'system_user_name',
'status',
];
protected $casts = [
'user_id' => 'integer',
'company_id' => 'integer',
'status' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 关联用户
*/
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* 关联公司
*/
public function company()
{
return $this->belongsTo(Company::class, 'company_id');
}
/**
* 获取有效的映射
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据系统代码查找
*/
public function scopeBySystem($query, string $systemCode)
{
return $query->where('system_code', $systemCode);
}
/**
* 根据公司ID查找
*/
public function scopeByCompany($query, int $companyId)
{
return $query->where('company_id', $companyId);
}
/**
* 根据外部系统用户ID查找
*/
public function scopeBySystemUserId($query, string $systemUserId, string $systemCode = 'fdesk')
{
return $query->where('system_code', $systemCode)
->where('system_user_id', $systemUserId);
}
/**
* 获取用户在指定系统的映射
*/
public function scopeUserMapping($query, int $userId, int $companyId, string $systemCode = 'fdesk')
{
return $query->where('user_id', $userId)
->where('company_id', $companyId)
->where('system_code', $systemCode);
}
}
-53
View File
@@ -1,53 +0,0 @@
<?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')]);
});
}
}
-228
View File
@@ -1,228 +0,0 @@
<?php
/**
* 阿里云 OSS 服务(在最开始设置错误报告)
*/
namespace app\Service;
class OssService
{
private string $accessKeyId;
private string $accessKeySecret;
private string $bucket;
private string $endpoint;
private string $staticUrl;
private string $mainDir;
private $ossClient = null;
public function __construct()
{
// 在构造函数开始时就禁用 deprecated 警告
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
try {
$this->accessKeyId = getenv('OSS_ACCESSID') ?: '';
$this->accessKeySecret = getenv('OSS_ACCESSSECRET') ?: '';
$this->bucket = getenv('OSS_BUCKET') ?: '';
$this->endpoint = getenv('OSS_ENDPOINT') ?: '';
$this->staticUrl = getenv('OSS_STATIC_URL') ?: '';
$this->mainDir = rtrim(getenv('OSS_MAIN_DIR') ?: 'uploads', '/');
if (empty($this->accessKeyId) || empty($this->accessKeySecret) || empty($this->bucket) || empty($this->endpoint)) {
throw new \Exception('OSS 配置不完整,请检查 .env 文件');
}
} finally {
error_reporting($oldLevel);
}
}
/**
* 获取 OSS Client 实例(延迟加载 + 错误抑制)
*/
private function getClient()
{
if ($this->ossClient === null) {
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
try {
$this->ossClient = new \OSS\OssClient($this->accessKeyId, $this->accessKeySecret, $this->endpoint);
} catch (\Throwable $e) {
throw new \Exception('OSS 初始化失败: ' . $e->getMessage());
} finally {
error_reporting($oldLevel);
}
}
return $this->ossClient;
}
/**
* 上传文件到 OSS
*
* @param string $filePath 本地文件路径
* @param string $objectKey OSS上存储的对象名称
* @return string 文件的公共访问 URL
* @throws \Exception
*/
public function uploadFile(string $filePath, string $objectKey): string
{
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
try {
$options = [
\OSS\OssClient::OSS_HEADERS => [
'Content-Type' => $this->getMimeType($filePath),
],
];
$this->getClient()->uploadFile($this->bucket, $objectKey, $filePath, $options);
return $this->getPublicUrl($objectKey);
} catch (\OSS\Core\OssException $e) {
throw new \Exception('OSS 上传失败: ' . $e->getMessage());
} catch (\Throwable $e) {
throw new \Exception('OSS 上传失败: ' . $e->getMessage());
} finally {
error_reporting($oldLevel);
}
}
/**
* 删除 OSS 上的文件
*
* @param string $objectKey OSS上存储的对象名称
* @return bool
* @throws \Exception
*/
public function deleteFile(string $objectKey): bool
{
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
try {
$this->getClient()->deleteObject($this->bucket, $objectKey);
return true;
} catch (\OSS\Core\OssException $e) {
throw new \Exception('OSS 删除失败: ' . $e->getMessage());
} catch (\Throwable $e) {
throw new \Exception('OSS 删除失败: ' . $e->getMessage());
} finally {
error_reporting($oldLevel);
}
}
/**
* 批量删除文件
*
* @param array $objectKeys 对象名称数组
* @return bool
* @throws \Exception
*/
public function deleteFiles(array $objectKeys): bool
{
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
try {
$this->getClient()->deleteObjects($this->bucket, $objectKeys);
return true;
} catch (\OSS\Core\OssException $e) {
throw new \Exception('OSS 批量删除失败: ' . $e->getMessage());
} catch (\Throwable $e) {
throw new \Exception('OSS 批量删除失败: ' . $e->getMessage());
} finally {
error_reporting($oldLevel);
}
}
/**
* 获取文件的公共访问 URL
*
* @param string $objectKey
* @return string
*/
public function getPublicUrl(string $objectKey): string
{
if (!empty($this->staticUrl)) {
return rtrim($this->staticUrl, '/') . '/' . $objectKey;
}
return 'https://' . $this->bucket . '.' . str_replace('https://', '', $this->endpoint) . '/' . $objectKey;
}
/**
* 生成带签名的访问 URL(用于私有bucket)
*
* @param string $objectKey
* @param int $timeout 有效期(秒)
* @return string
*/
public function getSignedUrl(string $objectKey, int $timeout = 3600): string
{
$oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
try {
return $this->getClient()->signUrl($this->bucket, $objectKey, $timeout);
} catch (\Throwable $e) {
return $this->getPublicUrl($objectKey);
} finally {
error_reporting($oldLevel);
}
}
/**
* 获取完整的对象键(包含主目录)
*
* @param string $path 相对路径
* @return string
*/
public function getObjectKey(string $path): string
{
$path = ltrim($path, '/');
return $this->mainDir ? ($this->mainDir . '/' . $path) : $path;
}
/**
* 检查 OSS 是否已配置
*
* @return bool
*/
public static function isConfigured(): bool
{
return !empty(getenv('OSS_ACCESSID'))
&& !empty(getenv('OSS_ACCESSSECRET'))
&& !empty(getenv('OSS_BUCKET'))
&& !empty(getenv('OSS_ENDPOINT'));
}
/**
* 获取文件的 MIME 类型
*
* @param string $filePath
* @return string
*/
private function getMimeType(string $filePath): string
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$mimeMap = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'bmp' => 'image/bmp',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'txt' => 'text/plain',
'zip' => 'application/zip',
];
return $mimeMap[$extension] ?? 'application/octet-stream';
}
/**
* 获取 OSS 主目录
*
* @return string
*/
public function getMainDir(): string
{
return $this->mainDir;
}
}
-521
View File
@@ -1,521 +0,0 @@
<?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();
// 保存车辆信息用于文件名(extract会覆盖$car变量)
$carCode = $car->code;
$carId = $car->id;
$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-' . $carCode . '-' . $carId . '-' . $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));
}
}
-329
View File
@@ -1,329 +0,0 @@
<?php
namespace app\Service;
use app\Model\UploadedFiles;
use Webman\Http\Request;
/**
* 文件上传服务
* 使用阿里云 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 OssService $ossService;
private string $tmpPath;
public function __construct()
{
$this->ossService = new OssService();
$this->tmpPath = sys_get_temp_dir();
}
/**
* 上传文件到 OSS
*
* @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();
$mimeType = $file->getUploadMimeType();
$extension = strtolower($file->getUploadExtension());
// 验证文件大小
if ($fileSize > $config['max_size']) {
$maxSizeMB = round($config['max_size'] / 1024 / 1024, 2);
throw new \Exception("文件大小超过限制,最大允许 {$maxSizeMB}MB");
}
// 验证文件类型
if (!empty($config['allowed_types']) && !in_array($mimeType, $config['allowed_types'])) {
throw new \Exception('不支持的文件类型: ' . $mimeType);
}
// 验证文件扩展名
if (!empty($config['extensions']) && !in_array($extension, $config['extensions'])) {
throw new \Exception('不支持的文件扩展名: .' . $extension);
}
// 移动到临时文件
$tmpFileName = 'upload_' . uniqid() . '_' . bin2hex(random_bytes(8)) . '.' . $extension;
$tmpFilePath = $this->tmpPath . '/' . $tmpFileName;
if (!$file->move($tmpFilePath)) {
throw new \Exception('文件保存到临时目录失败');
}
try {
// 图片处理(压缩)
if (str_starts_with($mimeType, 'image/')) {
$processedPath = $this->processImage($tmpFilePath);
if ($processedPath !== $tmpFilePath && file_exists($processedPath)) {
@unlink($tmpFilePath);
$tmpFilePath = $processedPath;
// 更新扩展名(如果转为 webp)
$extension = strtolower(pathinfo($tmpFilePath, PATHINFO_EXTENSION));
}
}
// 生成 OSS 对象键
$datePath = date('Ymd');
$fileName = $this->generateFileName($extension);
$objectKey = $this->ossService->getObjectKey("{$config['path']}/{$datePath}/{$fileName}");
// 上传到 OSS
$ossUrl = $this->ossService->uploadFile($tmpFilePath, $objectKey);
// 保存相对路径用于记录
$relativePath = str_replace($this->ossService->getMainDir() . '/', '', $objectKey);
$fileUrl = '/' . $relativePath;
// 记录到数据库
$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' => $ossUrl,
'oss_url' => $ossUrl,
'file_size' => $fileSize,
'file_type' => $mimeType,
'original_name' => $originalName,
'partition' => $partition,
'category' => $category,
];
} finally {
// 清理临时文件
if (file_exists($tmpFilePath)) {
@unlink($tmpFilePath);
}
}
}
/**
* 上传图片(专用接口)
*
* @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);
}
/**
* 删除文件(OSS + 数据库)
*
* @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('无权删除此文件');
}
// 从 file_path 构建完整的 OSS 对象键
// file_path 格式: /images/20250305/file.jpg
// 需要转换为: x_server_mobile/images/20250305/file.jpg
$relativePath = ltrim($file->file_path, '/');
$objectKey = $this->ossService->getObjectKey($relativePath);
try {
// 删除 OSS 文件
$this->ossService->deleteFile($objectKey);
} catch (\Exception $e) {
// OSS 删除失败,记录日志但继续删除数据库记录
error_log('删除 OSS 文件失败: ' . $e->getMessage());
}
// 删除数据库记录
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('文件不存在');
}
// 生成完整的 OSS URL
$relativePath = ltrim($file->file_path, '/');
$objectKey = $this->ossService->getObjectKey($relativePath);
$ossUrl = $this->ossService->getPublicUrl($objectKey);
return [
'id' => $file->id,
'file_name' => $file->file_name,
'file_path' => $file->file_path,
'full_url' => $ossUrl,
'oss_url' => $ossUrl,
'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(string $extension): string
{
$timestamp = time();
$random = bin2hex(random_bytes(4));
return $timestamp . '_' . $random . '.' . $extension;
}
/**
* 处理图片(压缩)
* 返回处理后的文件路径
*/
private function processImage(string $filePath): string
{
// 检查是否支持 imagick 扩展
if (!extension_loaded('imagick')) {
return $filePath;
}
try {
$image = new \Imagick($filePath);
// 限制最大尺寸
$maxWidth = 2000;
$maxHeight = 2000;
$image->thumbnailImage($maxWidth, $maxHeight, true);
// 自动方向修正
if (method_exists($image, 'autoOrient')) {
$image->autoOrient();
}
// 转换为 WebP 并压缩
$image->setImageFormat('webp');
$image->setImageCompressionQuality(80);
$webpPath = preg_replace('/\.(jpe?g|png)$/i', '.webp', $filePath);
$image->writeImage($webpPath);
$image->clear();
$image->destroy();
// 如果转换成功且文件名改变,返回新路径
if ($webpPath !== $filePath && file_exists($webpPath)) {
return $webpPath;
}
} catch (\Exception $e) {
// 处理失败,保留原图
error_log('图片处理失败: ' . $e->getMessage());
}
return $filePath;
}
/**
* 格式化文件大小
*/
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;
}
}
-682
View File
@@ -1,682 +0,0 @@
<?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;
}
}
-529
View File
@@ -1,529 +0,0 @@
<?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;
}
}
-55
View File
@@ -18,7 +18,6 @@ class FeishuUtil
private const string APP_ACCESS_TOKEN_URL = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
private const string USER_ACCESS_TOKEN_URL = 'https://open.feishu.cn/open-apis/authen/v1/oidc/access_token';
private const string USER_INFO_URL = 'https://open.feishu.cn/open-apis/authen/v1/user_info';
private const string JSAPI_TICKET_URL = 'https://open.feishu.cn/open-apis/jssdk/ticket/get';
/**
* 获取 app_access_token
@@ -201,58 +200,4 @@ class FeishuUtil
$userAccessToken = self::getUserAccessToken($code, $appId);
return self::getUserInfo($userAccessToken);
}
/**
* 获取 jsapi_ticket(用于 JS API 鉴权)
*
* @param int $appId 飞书应用ID
* @return string jsapi_ticket
* @throws Exception
*/
public static function getJsapiTicket(int $appId): string
{
$cacheKey = "feishu:jsapi_ticket:{$appId}";
// 从缓存获取
if (isset(self::$tokenCache[$cacheKey]) && self::$tokenCache[$cacheKey]['expire'] > time()) {
return self::$tokenCache[$cacheKey]['ticket'];
}
$appAccessToken = self::getAppAccessToken($appId);
try {
$response = HttpClient::httpGet(
self::JSAPI_TICKET_URL,
[],
[
'Authorization: Bearer ' . $appAccessToken,
]
);
if ($response['code'] !== 0) {
$errorMsg = $response['msg'] ?? $response['message'] ?? '未知错误';
throw new Exception('获取 jsapi_ticket 失败: ' . $errorMsg);
}
if (!isset($response['data']['ticket'])) {
throw new Exception('响应中缺少 ticket: ' . json_encode($response));
}
$ticket = $response['data']['ticket'];
$expire = $response['data']['expire_in'] ?? 7200;
// 缓存 ticket(提前5分钟过期)
self::$tokenCache[$cacheKey] = [
'ticket' => $ticket,
'expire' => time() + $expire - 300,
];
error_log("✅ 获取 jsapi_ticket 成功: " . substr($ticket, 0, 30) . "...");
return $ticket;
} catch (Exception $e) {
error_log("❌ 获取 jsapi_ticket 失败: " . $e->getMessage());
throw $e;
}
}
}
+1 -6
View File
@@ -14,12 +14,7 @@
"vlucas/phpdotenv": "^5.6",
"webman/database": "^2.1",
"symfony/var-dumper": "^8.0",
"webman/redis": "^2.1",
"guzzlehttp/guzzle": "^7.8",
"jaeger/querylist": "^4.4",
"mpdf/mpdf": "^8.2",
"workerman/crontab": "^1.0",
"aliyuncs/oss-sdk-php": "^2.7"
"webman/redis": "^2.1"
},
"require-dev": {
"mockery/mockery": "^1.5",
+1630 -1255
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
<?php
/**
* 自定义进程配置
* 用于注册定时任务等自定义进程
*/
return [
// 定时任务进程
'task' => [
'handler' => app\process\Task::class
],
];
+1 -29
View File
@@ -13,7 +13,6 @@ Route::group('/api/v1', function () {
Route::post('/auth/qrcode-login', [app\controller\AuthController::class, 'qrcodeLogin']);
Route::get('/auth/qrcode-login/status', [app\controller\AuthController::class, 'qrcodeLoginStatus']);
Route::get('/auth/feishu-config', [app\controller\AuthController::class, 'feishuConfig']);
Route::get('/auth/feishu-jsapi-signature', [app\controller\AuthController::class, 'feishuJsapiSignature']);
// 测试接口
Route::get('/test/notification', [app\controller\TestController::class, 'notification']);
@@ -37,6 +36,7 @@ Route::group('/api/v1/user', function () {
Route::get('/menu', [app\controller\UserController::class, 'menu']);
Route::get('/companies', [app\controller\UserController::class, 'companies']);
Route::get('/list', [app\controller\UserController::class, 'list']);
Route::get('/system-mapping', [app\controller\UserController::class, 'systemMapping']);
Route::post('/switch-company', [app\controller\UserController::class, 'switchCompany']);
Route::post('/change-password', [app\controller\UserController::class, 'changePassword']);
})->middleware($authMiddleware);
@@ -63,34 +63,6 @@ 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']);
@@ -1,56 +0,0 @@
-- 二手车相关数据表
-- 创建时间: 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生成日志表';
@@ -1,22 +0,0 @@
-- 上传文件记录表
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='上传文件记录表';
+36 -58
View File
@@ -900538,62 +900538,40 @@ INSERT INTO `users_copy1` VALUES (73, '833601', 1, '郭炯00078', 'https://s1-im
INSERT INTO `users_copy1` VALUES (74, '263364', 3, '李永春', 'https://s3-imfile.feishucdn.com/static-resource/v1/v3_00kb_ebbfbf17-c4b2-4306-8896-91ee3ef0b64g~?image_size=240x240&cut_type=&quality=&format=image&sticker_format=.webp', NULL, 'liyongchun@paikacar.com', '13073773542', '$2y$10$ed9m0Ecvj6FES2ntlRwedObN./Giv0.CmMZAvkKtA4dd2IB5PBbcm', 1, 1, 'on_bf01c7ea230962313781a54c927eba10', 2, 'x_server/user_signature/20260202/b9ce62914096acc13e12f16cc4740ef1_697ffc95e5338.png', '2026-01-29 09:50:17', '2026-02-26 16:18:51');
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');
-- ----------------------------
-- Table structure for user_system_mapping
-- ----------------------------
DROP TABLE IF EXISTS `user_system_mapping`;
CREATE TABLE `user_system_mapping` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` bigint UNSIGNED NOT NULL COMMENT '本项目用户ID',
`company_id` bigint UNSIGNED NOT NULL COMMENT '公司ID',
`system_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'fdesk' COMMENT '系统代码(如:fdesk, erp, crm)',
`system_user_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '外部系统用户ID',
`system_user_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '外部系统用户名(冗余字段,便于查看)',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1=有效,0=无效',
`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`) USING BTREE,
UNIQUE KEY `uk_user_company_system` (`user_id`, `company_id`, `system_code`) USING BTREE,
INDEX `idx_system_user_id`(`system_code`, `system_user_id`) USING BTREE,
INDEX `idx_company_id`(`company_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户系统映射表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user_system_mapping
-- ----------------------------
INSERT INTO `user_system_mapping` VALUES (1, 1, 2, 'fdesk', '1', '李佳博', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (2, 2, 2, 'fdesk', '74', '陈兰', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (3, 3, 2, 'fdesk', '78', '陈标', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (4, 4, 1, 'fdesk', '185', '闫继光', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (5, 76, 1, 'fdesk', '175', '王爽', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (6, 78, 3, 'fdesk', '175', '王爽', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (7, 79, 2, 'fdesk', '5', '闫继光', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (8, 82, 2, 'fdesk', '75', '范彩凤', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (9, 85, 1, 'fdesk', '183', '李冰杰', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (10, 88, 1, 'fdesk', '71', '崔琪', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (11, 90, 3, 'fdesk', '205', '李永春', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
INSERT INTO `user_system_mapping` VALUES (12, 92, 1, 'fdesk', '47', '朱海卫', 1, '2026-03-03 14:32:09', '2026-03-03 14:32:09');
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生成日志表';
@@ -1,84 +0,0 @@
<!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>
-156
View File
@@ -1,156 +0,0 @@
<!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>
+35 -5
View File
@@ -1,8 +1,8 @@
# 数据库结构文档
**版本**: 1.1.0
**更新时间**: 2026-03-01
**状态**: 线上稳定版本
**版本**: 1.1.1
**更新时间**: 2026-03-03
**状态**: 开发中
---
@@ -358,6 +358,34 @@
---
### 15. user_system_mapping - 用户系统映射表
用于将本项目用户ID映射到外部系统(如fdesk、ERP、CRM等)的用户ID。
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| id | bigint unsigned | ✓ | AUTO_INCREMENT | 记录ID |
| user_id | bigint unsigned | ✓ | | 本项目用户ID |
| company_id | bigint unsigned | ✓ | | 公司ID |
| system_code | varchar(50) | ✓ | fdesk | 系统代码(如:fdesk, erp, crm) |
| system_user_id | varchar(100) | ✓ | | 外部系统用户ID |
| system_user_name | varchar(100) | | | 外部系统用户名(冗余字段) |
| status | tinyint | ✓ | 1 | 状态:1=有效,0=无效 |
| created_at | timestamp | ✓ | CURRENT_TIMESTAMP | 创建时间 |
| updated_at | timestamp | ✓ | CURRENT_TIMESTAMP | 更新时间 |
**索引**:
- PRIMARY KEY (`id`)
- UNIQUE KEY `uk_user_company_system` (`user_id`, `company_id`, `system_code`)
- KEY `idx_system_user_id` (`system_code`, `system_user_id`)
- KEY `idx_company_id` (`company_id`)
**说明**:
- 同一用户在同一公司下对应一个外部系统只能有一条记录
- 支持多个外部系统的映射关系
---
## 📊 表关系图
```
@@ -368,6 +396,8 @@ users (1) ----< (N) user_feishu_apps
+----> companies (1) ----< (N) company_feishu_apps
|
+----> user_sn (N)
|
+----> user_system_mapping (N)
users (1) ----< (N) business_trip
| |
@@ -419,5 +449,5 @@ users (1) ----< (N) business_trip
---
**维护人**: 开发团队
**最后更新**: 2026-03-01
**版本**: 1.1.0 (线上稳定版)
**最后更新**: 2026-03-03
**版本**: 1.1.1
+289
View File
@@ -186,5 +186,294 @@
"lastMonth": "Last Month",
"thisYear": "This Year",
"lastYear": "Last Year"
},
"stock": {
"title": "Stock Order",
"order": "Stock Order",
"final": "Final Stock Order",
"entry": "Stock Entry",
"list": "Stock Order List",
"detail": "Stock Order Detail",
"edit": "Edit Stock Order",
"create": "Create Stock Order",
"createFinal": "Create Final",
"view": "View",
"viewFinal": "View Final",
"orderNo": "Order No.",
"piNo": "Alibaba Order/PI No.",
"customer": "Customer",
"customerName": "Customer Name",
"customerSource": "Customer Source",
"customerCountry": "Customer Country",
"salesperson": "Salesperson",
"bizDate": "Date",
"clearanceCountry": "Clearance Destination Country",
"remark": "Remark",
"approvalHistory": "Approval History",
"contractFile": "Contract File",
"piFile": "PI File",
"transactionSlipFile": "Transaction Slip File",
"vehicle": "Vehicle",
"fileN": "File {{num}}",
"totalRecords": "Total {{count}} records",
"deleteFailed": "Failed to delete",
"printFailed": "Failed to print",
"recallFailed": "Failed to recall",
"voidFailed": "Failed to void",
"voidSuccess": "Voided successfully",
"noContractFiles": "No contract files",
"noDetail": "No details",
"noPiFiles": "No PI files",
"noTransactionSlipFiles": "No transaction slip files",
"finalList": "Final List",
"finalDetail": "Final Detail",
"finalDetailTitle": "Final Stock Order Detail",
"viewDetails": "View Details",
"viewApproval": "View Approval",
"recallApproval": "Recall Approval",
"status": {
"draft": "Draft",
"pending": "Pending Approval",
"approved": "Approved",
"rejected": "Rejected",
"recalled": "Recalled",
"voided": "Voided"
},
"actions": {
"create": "Create",
"edit": "Edit",
"delete": "Delete",
"submit": "Submit",
"recall": "Recall",
"void": "Void",
"print": "Print",
"copy": "Copy",
"view": "View",
"save": "Save",
"cancel": "Cancel",
"add": "Add",
"recreate": "Recreate"
},
"detail": {
"baseInfo": "Basic Information",
"orderDetail": "Order Details",
"transportInfo": "Transport Details",
"paymentInfo": "Planned Payment",
"attachments": "Attachments",
"contract": "Contract Files",
"piDoc": "PI Files",
"transactionSlip": "Transaction Slip Files",
"totalCif": "Total CIF",
"contractRate": "Contract Exchange Rate"
},
"item": {
"model": "Model",
"exteriorColor": "Exterior Color",
"interiorColor": "Interior Color",
"mileage": "Mileage",
"vin": "VIN",
"unitPrice": "Collection Price",
"clearancePrice": "Clearance Price",
"carPrice": "Car Price",
"taxRefund": "Tax Refund",
"profit": "Profit",
"officialPrice": "Official Guide Price",
"discount": "Discount",
"listingPrice": "Used Listing Price",
"reduction": "Used Reduction",
"totalPrice": "Total Price",
"increasePrice": "Price Increase"
},
"transport": {
"declarant": "Clearance Agent",
"etaShipDate": "Estimated Ship Date",
"domesticPort": "Domestic Port",
"shippingMethod": "Shipping Method",
"tradeTerm": "Trade Term",
"clearancePort": "Clearance Port",
"latestArrival": "Latest Arrival Date"
},
"payment": {
"remitter": "Remitter",
"remitCountry": "Remitter Country",
"remitDate": "Remittance Date",
"account": "Account",
"amount": "Amount",
"paymentItem": "Payment {{num}}"
},
"fees": {
"handlingFee": "Handling Fee",
"retrofitFee": "Retrofit Fee",
"fobTransportFee": "FOB Freight",
"portMiscFee": "Port Miscellaneous Fee",
"oceanFreight": "Ocean Freight",
"domesticFreight": "Domestic Freight",
"commission": "Commission",
"purchaseTax": "Purchase Tax",
"taxRefund": "Tax Refund",
"accessoryPrice": "Accessory Price",
"retentionPrice": "Retention Account",
"carPrice": "Car Price",
"increasePrice": "Price Increase",
"clearancePrice": "Clearance Price",
"profit": "Profit",
"officialPrice": "Official Guide Price",
"discount": "Discount",
"listingPrice": "Used Listing Price",
"reduction": "Used Reduction",
"totalPrice": "Total"
},
"summary": {
"summaryInfo": "Summary Information",
"profit": "Profit",
"totalProfit": "Total Profit"
},
"dates": {
"pickUpDate": "Pick-up Date",
"collectAllDate": "All Collected Date"
},
"otherAttachments": "Other Attachments",
"searchPlaceholder": "Search customer name",
"empty": "No stock orders",
"emptyFinal": "No final stock orders",
"totalRecords": "Total {{count}} records",
"confirmDelete": "Are you sure to delete this stock order?",
"confirmRecall": "Are you sure to recall this stock order?",
"confirmVoid": "Are you sure to void this stock order?",
"confirmSubmit": "Confirm to submit?",
"deleteSuccess": "Deleted successfully",
"submitSuccess": "Submitted successfully",
"recallSuccess": "Recalled successfully",
"voidSuccess": "Voided successfully",
"saveSuccess": "Saved successfully",
"loadFailed": "Failed to load",
"loadOrderFailed": "Failed to load original order data",
"loadOrderSuccess": "Original order data loaded",
"saveFailed": "Failed to save",
"submitFailed": "Failed to submit",
"fileUploadSuccess": "File uploaded successfully!",
"fileUploadFailed": "File upload failed!",
"fileDeleted": "File deleted",
"uploading": "Uploading...",
"submitting": "Submitting...",
"atLeastOneItem": "At least one item required",
"atLeastOnePayment": "At least one payment required",
"deleteFailed": "Failed to delete",
"recallFailed": "Failed to recall",
"voidFailed": "Failed to void",
"printFailed": "Failed to print",
"noPrintFile": "No print file available",
"viewDetails": "View Details",
"viewApproval": "View Approval",
"submitForApproval": "Submit for Approval",
"recallApproval": "Recall Approval",
"loading": "Loading...",
"fileN": "File {{num}}",
"contractFile": "Contract File",
"piFile": "PI File",
"transactionSlipFile": "Transaction Slip File",
"fileUpload": {
"contract": "Upload Contract",
"piDoc": "Upload PI",
"transactionSlip": "Upload Transaction Slip",
"clickToUpload": "Click to upload",
"supportMultiple": "Support multiple file upload",
"preview": "Preview",
"download": "Download",
"delete": "Delete",
"fileTypeAccept": "Only {{accept}} format files are allowed!",
"fileSizeMax": "File size cannot exceed {{maxSize}}MB!"
},
"validation": {
"enterPiNo": "Please enter Alibaba Order/PI No.",
"selectDate": "Please select date",
"selectClearanceCountry": "Please select clearance destination country",
"selectCustomer": "Please select customer",
"enterDeclarant": "Please enter clearance agent",
"selectEtaShipDate": "Please select estimated ship date",
"selectDomesticPort": "Please select domestic port",
"selectShippingMethod": "Please select shipping method",
"selectTradeTerm": "Please select trade term",
"selectClearancePort": "Please select clearance port",
"selectLatestArrival": "Please select latest arrival date",
"selectAllVehicles": "Please select vehicles for all models",
"uploadContract": "Please upload contract file",
"uploadPiDoc": "Please upload PI file",
"uploadTransactionSlip": "Please upload transaction slip file",
"selectPickUpDate": "Please select pick-up date",
"selectCollectAllDate": "Please select all collected date"
},
"placeholder": {
"autoCalculate": "Auto Calculate",
"enterPiNo": "Please enter Alibaba Order/PI No.",
"selectDate": "Please select date",
"selectClearanceCountry": "Please select clearance destination country",
"selectCustomer": "Please select customer",
"autoFillCustomerSource": "Auto-filled after selecting customer",
"autoFillCustomerCountry": "Auto-filled after selecting customer",
"selectVehicle": "Please select vehicle model",
"selectExteriorColor": "Please select exterior color",
"selectInteriorColor": "Please select interior color",
"newCarMileage": "0 for new car",
"enterVin": "Please enter VIN",
"enterOfficialPrice": "Please enter official guide price",
"enterListingPrice": "Please enter used listing price",
"enterDiscount": "Please enter discount amount",
"enterAmount": "Please enter",
"enterIncreasePrice": "Please enter price increase",
"enterUnitPrice": "Please enter collection price",
"calculatingClearancePrice": "Calculating clearance price",
"calculatingCarPrice": "Calculating car price",
"calculatingTaxRefund": "Calculating estimated tax refund",
"calculatingProfit": "Calculating profit",
"enterRemark": "Please enter remark",
"enterHandlingFee": "Please enter",
"enterContractRate": "Please enter contract exchange rate",
"enterDeclarant": "Please enter clearance agent",
"selectDomesticPort": "Please select domestic port",
"selectShippingMethod": "Please select shipping method",
"selectTradeTerm": "Please select trade term",
"selectClearancePort": "Please select clearance port",
"enterRemitter": "Please enter remitter",
"selectRemitCountry": "Please select remitter country",
"selectAccount": "Please select account",
"enterAmount": "Please enter amount",
"searchCustomer": "Search customer",
"searchVehicle": "Search vehicle",
"selectPickUpDate": "Please select pick-up date",
"selectCollectAllDate": "Please select all collected date",
"enterMileage": "Please enter mileage"
},
"entry": {
"edit": "Edit Stock Order",
"copy": "Copy Stock Order",
"title": "Stock Entry",
"baseInfo": "Basic Information",
"baseInfoReadOnly": "Basic Information (Read Only)",
"orderDetail": "Order Details",
"transportInfo": "Transport Details",
"paymentInfo": "Planned Payment",
"fileUpload": "File Upload",
"saveDraft": "Save Draft",
"submitApproval": "Submit for Approval",
"createFinal": "Create Final",
"detail": "Detail",
"totalPrice": "Total",
"unitPrice": "Unit Price",
"carPrice": "Car Price",
"profit": "Profit",
"officialPrice": "Official Guide Price",
"discount": "Discount",
"listingPrice": "Used Listing Price",
"reduction": "Used Reduction",
"add": "Add",
"copy": "Copy"
},
"select": {
"customer": "Select Customer",
"vehicle": "Select Vehicle",
"year": "Year",
"msrp": "Guide Price"
}
}
}
+289
View File
@@ -161,5 +161,294 @@
"lastMonth": "Mes Pasado",
"thisYear": "Este Año",
"lastYear": "Año Pasado"
},
"stock": {
"title": "Pedido de Stock",
"order": "Pedido de Stock",
"final": "Pedido Final",
"entry": "Entrada de Stock",
"list": "Lista de Pedidos",
"detail": "Detalle del Pedido",
"edit": "Editar Pedido",
"create": "Crear Pedido",
"createFinal": "Crear Final",
"view": "Ver",
"viewFinal": "Ver Final",
"orderNo": "Nº Pedido",
"piNo": "Pedido Alibaba/Nº PI",
"customer": "Cliente",
"customerName": "Nombre del Cliente",
"customerSource": "Fuente del Cliente",
"customerCountry": "País del Cliente",
"salesperson": "Vendedor",
"bizDate": "Fecha",
"clearanceCountry": "País de Destino Aduanero",
"remark": "Observación",
"approvalHistory": "Historial de Aprobación",
"contractFile": "Archivo de Contrato",
"piFile": "Archivo PI",
"transactionSlipFile": "Justificante de Transacción",
"vehicle": "Vehículo",
"fileN": "Archivo {{num}}",
"totalRecords": "{{count}} registros en total",
"deleteFailed": "Error al eliminar",
"printFailed": "Error al imprimir",
"recallFailed": "Error al recuperar",
"voidFailed": "Error al anular",
"voidSuccess": "Anulado con éxito",
"noContractFiles": "Sin archivos de contrato",
"noDetail": "Sin detalles",
"noPiFiles": "Sin archivos PI",
"noTransactionSlipFiles": "Sin justificantes de transacción",
"finalList": "Lista Final",
"finalDetail": "Detalle Final",
"finalDetailTitle": "Detalle del Pedido Final",
"viewDetails": "Ver Detalles",
"viewApproval": "Ver Aprobación",
"recallApproval": "Recuperar Aprobación",
"status": {
"draft": "Borrador",
"pending": "Pendiente de Aprobación",
"approved": "Aprobado",
"rejected": "Rechazado",
"recalled": "Recuperado",
"voided": "Anulado"
},
"actions": {
"create": "Crear",
"edit": "Editar",
"delete": "Eliminar",
"submit": "Enviar",
"recall": "Recuperar",
"void": "Anular",
"print": "Imprimir",
"copy": "Copiar",
"view": "Ver",
"save": "Guardar",
"cancel": "Cancelar",
"add": "Agregar",
"recreate": "Recrear"
},
"detail": {
"baseInfo": "Información Básica",
"orderDetail": "Detalles del Pedido",
"transportInfo": "Detalles de Transporte",
"paymentInfo": "Pago Planificado",
"attachments": "Adjuntos",
"contract": "Archivos de Contrato",
"piDoc": "Archivos PI",
"transactionSlip": "Justificantes de Transacción",
"totalCif": "CIF Total",
"contractRate": "Tipo de Cambio del Contrato"
},
"item": {
"model": "Modelo",
"exteriorColor": "Color Exterior",
"interiorColor": "Color Interior",
"mileage": "Kilometraje",
"vin": "VIN",
"unitPrice": "Precio de Cobro",
"clearancePrice": "Precio de Aduana",
"carPrice": "Precio del Coche",
"taxRefund": "Devolución de Impuestos",
"profit": "Beneficio",
"officialPrice": "Precio Oficial",
"discount": "Descuento",
"listingPrice": "Precio de Segunda Mano",
"reduction": "Reducción Segunda Mano",
"totalPrice": "Precio Total",
"increasePrice": "Incremento de Precio"
},
"transport": {
"declarant": "Agente de Aduanas",
"etaShipDate": "Fecha Estimada de Envío",
"domesticPort": "Puerto Nacional",
"shippingMethod": "Método de Envío",
"tradeTerm": "Incoterms",
"clearancePort": "Puerto de Aduana",
"latestArrival": "Última Fecha de Llegada"
},
"payment": {
"remitter": "Remitente",
"remitCountry": "País del Remitente",
"remitDate": "Fecha de Transferencia",
"account": "Cuenta",
"amount": "Importe",
"paymentItem": "Pago {{num}}"
},
"fees": {
"handlingFee": "Tarifa de Gestión",
"retrofitFee": "Tarifa de Adaptación",
"fobTransportFee": "Transporte FOB",
"portMiscFee": "Gastos Portuarios",
"oceanFreight": "Flete Marítimo",
"domesticFreight": "Flete Nacional",
"commission": "Comisión",
"purchaseTax": "Impuesto de Compra",
"taxRefund": "Devolución de Impuestos",
"accessoryPrice": "Precio de Accesorios",
"retentionPrice": "Retención en Cuenta",
"carPrice": "Precio del Coche",
"increasePrice": "Incremento de Precio",
"clearancePrice": "Precio de Aduana",
"profit": "Beneficio",
"officialPrice": "Precio Oficial",
"discount": "Descuento",
"listingPrice": "Precio de Segunda Mano",
"reduction": "Reducción Segunda Mano",
"totalPrice": "Precio Total"
},
"summary": {
"summaryInfo": "Resumen de Información",
"profit": "Beneficio",
"totalProfit": "Beneficio Total"
},
"dates": {
"pickUpDate": "Fecha de Recogida",
"collectAllDate": "Fecha de Recolección Completa"
},
"otherAttachments": "Otros Adjuntos",
"searchPlaceholder": "Buscar nombre del cliente",
"empty": "Sin pedidos",
"emptyFinal": "Sin pedidos finales",
"totalRecords": "{{count}} registros en total",
"confirmDelete": "¿Está seguro de eliminar este pedido?",
"confirmRecall": "¿Está seguro de recuperar este pedido?",
"confirmVoid": "¿Está seguro de anular este pedido?",
"confirmSubmit": "¿Confirmar envío?",
"deleteSuccess": "Eliminado con éxito",
"submitSuccess": "Enviado con éxito",
"recallSuccess": "Recuperado con éxito",
"voidSuccess": "Anulado con éxito",
"saveSuccess": "Guardado con éxito",
"loadFailed": "Error al cargar",
"loadOrderFailed": "Error al cargar datos del pedido original",
"loadOrderSuccess": "Datos del pedido original cargados",
"saveFailed": "Error al guardar",
"submitFailed": "Error al enviar",
"fileUploadSuccess": "¡Archivo subido con éxito!",
"fileUploadFailed": "¡Error al subir el archivo!",
"fileDeleted": "Archivo eliminado",
"uploading": "Subiendo...",
"submitting": "Enviando...",
"atLeastOneItem": "Se requiere al menos un artículo",
"atLeastOnePayment": "Se requiere al menos un pago",
"deleteFailed": "Error al eliminar",
"recallFailed": "Error al recuperar",
"voidFailed": "Error al anular",
"printFailed": "Error al imprimir",
"noPrintFile": "Archivo de impresión no disponible",
"viewDetails": "Ver Detalles",
"viewApproval": "Ver Aprobación",
"submitForApproval": "Enviar para Aprobación",
"recallApproval": "Recuperar Aprobación",
"loading": "Cargando...",
"fileN": "Archivo {{num}}",
"contractFile": "Archivo de Contrato",
"piFile": "Archivo PI",
"transactionSlipFile": "Justificante de Transacción",
"fileUpload": {
"contract": "Subir Contrato",
"piDoc": "Subir PI",
"transactionSlip": "Subir Justificante",
"clickToUpload": "Haz clic para subir",
"supportMultiple": "Soporte para carga múltiple",
"preview": "Vista Previa",
"download": "Descargar",
"delete": "Eliminar",
"fileTypeAccept": "¡Solo se permiten archivos {{accept}}!",
"fileSizeMax": "¡El tamaño del archivo no puede exceder {{maxSize}}MB!"
},
"validation": {
"enterPiNo": "Por favor ingrese el Pedido Alibaba/Nº PI",
"selectDate": "Por favor seleccione fecha",
"selectClearanceCountry": "Por favor seleccione país de destino aduanero",
"selectCustomer": "Por favor seleccione cliente",
"enterDeclarant": "Por favor ingrese el agente de aduanas",
"selectEtaShipDate": "Por favor seleccione fecha estimada de envío",
"selectDomesticPort": "Por favor seleccione puerto nacional",
"selectShippingMethod": "Por favor seleccione método de envío",
"selectTradeTerm": "Por favor seleccione Incoterms",
"selectClearancePort": "Por favor seleccione puerto de aduana",
"selectLatestArrival": "Por favor seleccione última fecha de llegada",
"selectAllVehicles": "Por favor seleccione vehículos para todos los modelos",
"uploadContract": "Por favor suba el archivo de contrato",
"uploadPiDoc": "Por favor suba el archivo PI",
"uploadTransactionSlip": "Por favor suba el justificante de transacción",
"selectPickUpDate": "Por favor seleccione fecha de recogida",
"selectCollectAllDate": "Por favor seleccione fecha de recolección completa"
},
"placeholder": {
"autoCalculate": "Cálculo Automático",
"enterPiNo": "Por favor ingrese el Pedido Alibaba/Nº PI",
"selectDate": "Por favor seleccione fecha",
"selectClearanceCountry": "Por favor seleccione país de destino aduanero",
"selectCustomer": "Por favor seleccione cliente",
"autoFillCustomerSource": "Autocompletado al seleccionar cliente",
"autoFillCustomerCountry": "Autocompletado al seleccionar cliente",
"selectVehicle": "Por favor seleccione modelo de vehículo",
"selectExteriorColor": "Por favor seleccione color exterior",
"selectInteriorColor": "Por favor seleccione color interior",
"newCarMileage": "0 para vehículo nuevo",
"enterVin": "Por favor ingrese VIN",
"enterOfficialPrice": "Por favor ingrese precio oficial",
"enterListingPrice": "Por favor ingrese precio de segunda mano",
"enterDiscount": "Por favor ingrese monto de descuento",
"enterAmount": "Por favor ingrese",
"enterIncreasePrice": "Por favor ingrese incremento de precio",
"enterUnitPrice": "Por favor ingrese precio de cobro",
"calculatingClearancePrice": "Calculando precio de aduana",
"calculatingCarPrice": "Calculando precio del coche",
"calculatingTaxRefund": "Calculando devolución de impuestos estimada",
"calculatingProfit": "Calculando beneficio",
"enterRemark": "Por favor ingrese observación",
"enterHandlingFee": "Por favor ingrese",
"enterContractRate": "Por favor ingrese tipo de cambio del contrato",
"enterDeclarant": "Por favor ingrese agente de aduanas",
"selectDomesticPort": "Por favor seleccione puerto nacional",
"selectShippingMethod": "Por favor seleccione método de envío",
"selectTradeTerm": "Por favor seleccione Incoterms",
"selectClearancePort": "Por favor seleccione puerto de aduana",
"enterRemitter": "Por favor ingrese remitente",
"selectRemitCountry": "Por favor seleccione país del remitente",
"selectAccount": "Por favor seleccione cuenta",
"enterAmount": "Por favor ingrese monto",
"searchCustomer": "Buscar cliente",
"searchVehicle": "Buscar vehículo",
"selectPickUpDate": "Por favor seleccione fecha de recogida",
"selectCollectAllDate": "Por favor seleccione fecha de recolección completa",
"enterMileage": "Por favor ingrese kilometraje"
},
"entry": {
"edit": "Editar Pedido",
"copy": "Copiar Pedido",
"title": "Entrada de Stock",
"baseInfo": "Información Básica",
"baseInfoReadOnly": "Información Básica (Solo Lectura)",
"orderDetail": "Detalles del Pedido",
"transportInfo": "Detalles de Transporte",
"paymentInfo": "Pago Planificado",
"fileUpload": "Subir Archivos",
"saveDraft": "Guardar Borrador",
"submitApproval": "Enviar para Aprobación",
"createFinal": "Crear Final",
"detail": "Detalle",
"totalPrice": "Total",
"unitPrice": "Precio Unitario",
"carPrice": "Precio del Coche",
"profit": "Beneficio",
"officialPrice": "Precio Oficial",
"discount": "Descuento",
"listingPrice": "Precio de Segunda Mano",
"reduction": "Reducción Segunda Mano",
"add": "Agregar",
"copy": "Copiar"
},
"select": {
"customer": "Seleccionar Cliente",
"vehicle": "Seleccionar Vehículo",
"year": "Año",
"msrp": "Precio de Catálogo"
}
}
}
+289
View File
@@ -161,5 +161,294 @@
"lastMonth": "Mois Dernier",
"thisYear": "Cette Année",
"lastYear": "Année Dernière"
},
"stock": {
"title": "Commande de Stock",
"order": "Commande de Stock",
"final": "Commande Finale",
"entry": "Saisie de Stock",
"list": "Liste des Commandes",
"detail": "Détail de la Commande",
"edit": "Modifier la Commande",
"create": "Créer une Commande",
"createFinal": "Créer Finale",
"view": "Voir",
"viewFinal": "Voir Finale",
"orderNo": "Nº Commande",
"piNo": "Commande Alibaba/Nº PI",
"customer": "Client",
"customerName": "Nom du Client",
"customerSource": "Source du Client",
"customerCountry": "Pays du Client",
"salesperson": "Commercial",
"bizDate": "Date",
"clearanceCountry": "Pays de Dédouanement",
"remark": "Remarque",
"approvalHistory": "Historique d'Approbation",
"contractFile": "Fichier de Contrat",
"piFile": "Fichier PI",
"transactionSlipFile": "Justificatif de Transaction",
"vehicle": "Véhicule",
"fileN": "Fichier {{num}}",
"totalRecords": "{{count}} enregistrements au total",
"deleteFailed": "Échec de la suppression",
"printFailed": "Échec de l'impression",
"recallFailed": "Échec du rappel",
"voidFailed": "Échec de l'annulation",
"voidSuccess": "Annulé avec succès",
"noContractFiles": "Aucun fichier de contrat",
"noDetail": "Aucun détail",
"noPiFiles": "Aucun fichier PI",
"noTransactionSlipFiles": "Aucun justificatif de transaction",
"finalList": "Liste Finale",
"finalDetail": "Détail Final",
"finalDetailTitle": "Détail de la Commande Finale",
"viewDetails": "Voir les Détails",
"viewApproval": "Voir l'Approbation",
"recallApproval": "Rappeler l'Approbation",
"status": {
"draft": "Brouillon",
"pending": "En Attente d'Approbation",
"approved": "Approuvé",
"rejected": "Rejeté",
"recalled": "Rappelé",
"voided": "Annulé"
},
"actions": {
"create": "Créer",
"edit": "Modifier",
"delete": "Supprimer",
"submit": "Soumettre",
"recall": "Rappeler",
"void": "Annuler",
"print": "Imprimer",
"copy": "Copier",
"view": "Voir",
"save": "Enregistrer",
"cancel": "Annuler",
"add": "Ajouter",
"recreate": "Recréer"
},
"detail": {
"baseInfo": "Informations de Base",
"orderDetail": "Détails de la Commande",
"transportInfo": "Détails du Transport",
"paymentInfo": "Paiement Planifié",
"attachments": "Pièces Jointes",
"contract": "Fichiers de Contrat",
"piDoc": "Fichiers PI",
"transactionSlip": "Justificatifs de Transaction",
"totalCif": "CIF Total",
"contractRate": "Taux de Change du Contrat"
},
"item": {
"model": "Modèle",
"exteriorColor": "Couleur Extérieure",
"interiorColor": "Couleur Intérieure",
"mileage": "Kilométrage",
"vin": "VIN",
"unitPrice": "Prix d'Encaissement",
"clearancePrice": "Prix de Douane",
"carPrice": "Prix du Véhicule",
"taxRefund": "Remboursement Fiscal",
"profit": "Bénéfice",
"officialPrice": "Prix Officiel",
"discount": "Remise",
"listingPrice": "Prix d'Occasion",
"reduction": "Réduction d'Occasion",
"totalPrice": "Prix Total",
"increasePrice": "Augmentation de Prix"
},
"transport": {
"declarant": "Transitaire",
"etaShipDate": "Date d'Expédition Estimée",
"domesticPort": "Port National",
"shippingMethod": "Mode de Transport",
"tradeTerm": "Incoterms",
"clearancePort": "Port de Dédouanement",
"latestArrival": "Dernière Date d'Arrivée"
},
"payment": {
"remitter": "Émetteur",
"remitCountry": "Pays de l'Émetteur",
"remitDate": "Date du Virement",
"account": "Compte",
"amount": "Montant",
"paymentItem": "Paiement {{num}}"
},
"fees": {
"handlingFee": "Frais de Traitement",
"retrofitFee": "Frais d'Équipement",
"fobTransportFee": "Transport FOB",
"portMiscFee": "Frais Portuaires",
"oceanFreight": "Fret Maritime",
"domesticFreight": "Fret National",
"commission": "Commission",
"purchaseTax": "Taxe d'Achat",
"taxRefund": "Remboursement Fiscal",
"accessoryPrice": "Prix des Accessoires",
"retentionPrice": "Retenue sur Compte",
"carPrice": "Prix du Véhicule",
"increasePrice": "Augmentation de Prix",
"clearancePrice": "Prix de Douane",
"profit": "Bénéfice",
"officialPrice": "Prix Officiel",
"discount": "Remise",
"listingPrice": "Prix d'Occasion",
"reduction": "Réduction d'Occasion",
"totalPrice": "Prix Total"
},
"summary": {
"summaryInfo": "Résumé des Informations",
"profit": "Bénéfice",
"totalProfit": "Bénéfice Total"
},
"dates": {
"pickUpDate": "Date de Prise en Charge",
"collectAllDate": "Date de Collecte Complète"
},
"otherAttachments": "Autres Pièces Jointes",
"searchPlaceholder": "Rechercher nom du client",
"empty": "Aucune commande",
"emptyFinal": "Aucune commande finale",
"totalRecords": "{{count}} enregistrements au total",
"confirmDelete": "Confirmer la suppression de cette commande?",
"confirmRecall": "Confirmer le rappel de cette commande?",
"confirmVoid": "Confirmer l'annulation de cette commande?",
"confirmSubmit": "Confirmer la soumission?",
"deleteSuccess": "Supprimé avec succès",
"submitSuccess": "Soumis avec succès",
"recallSuccess": "Rappelé avec succès",
"voidSuccess": "Annulé avec succès",
"saveSuccess": "Enregistré avec succès",
"loadFailed": "Échec du chargement",
"loadOrderFailed": "Échec du chargement des données de la commande originale",
"loadOrderSuccess": "Données de la commande originale chargées",
"saveFailed": "Échec de l'enregistrement",
"submitFailed": "Échec de la soumission",
"fileUploadSuccess": "Fichier téléchargé avec succès!",
"fileUploadFailed": "Échec du téléchargement du fichier!",
"fileDeleted": "Fichier supprimé",
"uploading": "Téléchargement...",
"submitting": "Soumission...",
"atLeastOneItem": "Au moins un article requis",
"atLeastOnePayment": "Au moins un paiement requis",
"deleteFailed": "Échec de la suppression",
"recallFailed": "Échec du rappel",
"voidFailed": "Échec de l'annulation",
"printFailed": "Échec de l'impression",
"noPrintFile": "Fichier d'impression non disponible",
"viewDetails": "Voir les Détails",
"viewApproval": "Voir l'Approbation",
"submitForApproval": "Soumettre pour Approbation",
"recallApproval": "Rappeler l'Approbation",
"loading": "Chargement...",
"fileN": "Fichier {{num}}",
"contractFile": "Fichier de Contrat",
"piFile": "Fichier PI",
"transactionSlipFile": "Justificatif de Transaction",
"fileUpload": {
"contract": "Télécharger un Contrat",
"piDoc": "Télécharger un PI",
"transactionSlip": "Télécharger un Justificatif",
"clickToUpload": "Cliquez pour télécharger",
"supportMultiple": "Support du téléchargement multiple",
"preview": "Aperçu",
"download": "Télécharger",
"delete": "Supprimer",
"fileTypeAccept": "Seuls les fichiers {{accept}} sont autorisés!",
"fileSizeMax": "La taille du fichier ne peut pas dépasser {{maxSize}}Mo!"
},
"validation": {
"enterPiNo": "Veuillez entrer la Commande Alibaba/Nº PI",
"selectDate": "Veuillez sélectionner la date",
"selectClearanceCountry": "Veuillez sélectionner le pays de dédouanement",
"selectCustomer": "Veuillez sélectionner le client",
"enterDeclarant": "Veuillez entrer le transitaire",
"selectEtaShipDate": "Veuillez sélectionner la date d'expédition estimée",
"selectDomesticPort": "Veuillez sélectionner le port national",
"selectShippingMethod": "Veuillez sélectionner le mode de transport",
"selectTradeTerm": "Veuillez sélectionner les Incoterms",
"selectClearancePort": "Veuillez sélectionner le port de dédouanement",
"selectLatestArrival": "Veuillez sélectionner la dernière date d'arrivée",
"selectAllVehicles": "Veuillez sélectionner les véhicules pour tous les modèles",
"uploadContract": "Veuillez télécharger le fichier de contrat",
"uploadPiDoc": "Veuillez télécharger le fichier PI",
"uploadTransactionSlip": "Veuillez télécharger le justificatif de transaction",
"selectPickUpDate": "Veuillez sélectionner la date de prise en charge",
"selectCollectAllDate": "Veuillez sélectionner la date de collecte complète"
},
"placeholder": {
"autoCalculate": "Calcul Automatique",
"enterPiNo": "Veuillez entrer la Commande Alibaba/Nº PI",
"selectDate": "Veuillez sélectionner la date",
"selectClearanceCountry": "Veuillez sélectionner le pays de dédouanement",
"selectCustomer": "Veuillez sélectionner le client",
"autoFillCustomerSource": "Rempli automatiquement après sélection du client",
"autoFillCustomerCountry": "Rempli automatiquement après sélection du client",
"selectVehicle": "Veuillez sélectionner le modèle de véhicule",
"selectExteriorColor": "Veuillez sélectionner la couleur extérieure",
"selectInteriorColor": "Veuillez sélectionner la couleur intérieure",
"newCarMileage": "0 pour véhicule neuf",
"enterVin": "Veuillez entrer le VIN",
"enterOfficialPrice": "Veuillez entrer le prix officiel",
"enterListingPrice": "Veuillez entrer le prix d'occasion",
"enterDiscount": "Veuillez entrer le montant de la remise",
"enterAmount": "Veuillez entrer",
"enterIncreasePrice": "Veuillez entrer l'augmentation de prix",
"enterUnitPrice": "Veuillez entrer le prix d'encaissement",
"calculatingClearancePrice": "Calcul du prix de douane",
"calculatingCarPrice": "Calcul du prix du véhicule",
"calculatingTaxRefund": "Calcul du remboursement fiscal estimé",
"calculatingProfit": "Calcul du bénéfice",
"enterRemark": "Veuillez entrer une remarque",
"enterHandlingFee": "Veuillez entrer",
"enterContractRate": "Veuillez entrer le taux de change du contrat",
"enterDeclarant": "Veuillez entrer le transitaire",
"selectDomesticPort": "Veuillez sélectionner le port national",
"selectShippingMethod": "Veuillez sélectionner le mode de transport",
"selectTradeTerm": "Veuillez sélectionner les Incoterms",
"selectClearancePort": "Veuillez sélectionner le port de dédouanement",
"enterRemitter": "Veuillez entrer l'émetteur",
"selectRemitCountry": "Veuillez sélectionner le pays de l'émetteur",
"selectAccount": "Veuillez sélectionner le compte",
"enterAmount": "Veuillez entrer le montant",
"searchCustomer": "Rechercher client",
"searchVehicle": "Rechercher véhicule",
"selectPickUpDate": "Veuillez sélectionner la date de prise en charge",
"selectCollectAllDate": "Veuillez sélectionner la date de collecte complète",
"enterMileage": "Veuillez entrer le kilométrage"
},
"entry": {
"edit": "Modifier la Commande",
"copy": "Copier la Commande",
"title": "Saisie de Stock",
"baseInfo": "Informations de Base",
"baseInfoReadOnly": "Informations de Base (Lecture Seule)",
"orderDetail": "Détails de la Commande",
"transportInfo": "Détails du Transport",
"paymentInfo": "Paiement Planifié",
"fileUpload": "Télécharger Fichiers",
"saveDraft": "Enregistrer le Brouillon",
"submitApproval": "Soumettre pour Approbation",
"createFinal": "Créer Finale",
"detail": "Détail",
"totalPrice": "Total",
"unitPrice": "Prix Unitaire",
"carPrice": "Prix du Véhicule",
"profit": "Bénéfice",
"officialPrice": "Prix Officiel",
"discount": "Remise",
"listingPrice": "Prix d'Occasion",
"reduction": "Réduction d'Occasion",
"add": "Ajouter",
"copy": "Copier"
},
"select": {
"customer": "Sélectionner Client",
"vehicle": "Sélectionner Véhicule",
"year": "Année",
"msrp": "Prix Catalogue"
}
}
}
+289
View File
@@ -161,5 +161,294 @@
"lastMonth": "В прошлом месяце",
"thisYear": "В этом году",
"lastYear": "В прошлом году"
},
"stock": {
"title": "Заказ на поставку",
"order": "Заказ на поставку",
"final": "Финальный заказ",
"entry": "Ввод заказа",
"list": "Список заказов",
"detail": "Детали заказа",
"edit": "Редактировать заказ",
"create": "Создать заказ",
"createFinal": "Создать финальный",
"view": "Просмотр",
"viewFinal": "Просмотр финального",
"orderNo": "№ Заказа",
"piNo": "Заказ Alibaba/№ PI",
"customer": "Клиент",
"customerName": "Имя клиента",
"customerSource": "Источник клиента",
"customerCountry": "Страна клиента",
"salesperson": "Менеджер",
"bizDate": "Дата",
"clearanceCountry": "Страна таможенной очистки",
"remark": "Примечание",
"approvalHistory": "История согласования",
"contractFile": "Файл контракта",
"piFile": "Файл PI",
"transactionSlipFile": "Банковская квитанция",
"vehicle": "Транспортное средство",
"fileN": "Файл {{num}}",
"totalRecords": "Всего {{count}} записей",
"deleteFailed": "Ошибка удаления",
"printFailed": "Ошибка печати",
"recallFailed": "Ошибка отзыва",
"voidFailed": "Ошибка аннулирования",
"voidSuccess": "Аннулировано успешно",
"noContractFiles": "Нет файлов контракта",
"noDetail": "Нет деталей",
"noPiFiles": "Нет файлов PI",
"noTransactionSlipFiles": "Нет банковских квитанций",
"finalList": "Список финальных",
"finalDetail": "Детали финального",
"finalDetailTitle": "Детали финального заказа",
"viewDetails": "Просмотр деталей",
"viewApproval": "Просмотр согласования",
"recallApproval": "Отозвать согласование",
"status": {
"draft": "Черновик",
"pending": "На согласовании",
"approved": "Согласовано",
"rejected": "Отклонено",
"recalled": "Отозвано",
"voided": "Аннулировано"
},
"actions": {
"create": "Создать",
"edit": "Редактировать",
"delete": "Удалить",
"submit": "Отправить",
"recall": "Отозвать",
"void": "Аннулировать",
"print": "Печать",
"copy": "Копировать",
"view": "Просмотр",
"save": "Сохранить",
"cancel": "Отмена",
"add": "Добавить",
"recreate": "Пересоздать"
},
"detail": {
"baseInfo": "Основная информация",
"orderDetail": "Детали заказа",
"transportInfo": "Детали транспортировки",
"paymentInfo": "Планируемая оплата",
"attachments": "Вложения",
"contract": "Файлы контракта",
"piDoc": "Файлы PI",
"transactionSlip": "Банковские квитанции",
"totalCif": "Итого CIF",
"contractRate": "Курс обмена по контракту"
},
"item": {
"model": "Модель",
"exteriorColor": "Цвет экстерьера",
"interiorColor": "Цвет интерьера",
"mileage": "Пробег",
"vin": "VIN",
"unitPrice": "Цена получения",
"clearancePrice": "Таможенная цена",
"carPrice": "Цена автомобиля",
"taxRefund": "Налоговый возврат",
"profit": "Прибыль",
"officialPrice": "Рекомендуемая цена",
"discount": "Скидка",
"listingPrice": "Цена подержанного",
"reduction": "Уценка подержанного",
"totalPrice": "Общая цена",
"increasePrice": "Наценка"
},
"transport": {
"declarant": "Таможенный брокер",
"etaShipDate": "Ожидаемая дата отправки",
"domesticPort": "Внутренний порт",
"shippingMethod": "Способ доставки",
"tradeTerm": "Торговые условия",
"clearancePort": "Порт таможенной очистки",
"latestArrival": "Последняя дата прибытия"
},
"payment": {
"remitter": "Отправитель",
"remitCountry": "Страна отправителя",
"remitDate": "Дата перевода",
"account": "Счет",
"amount": "Сумма",
"paymentItem": "Оплата {{num}}"
},
"fees": {
"handlingFee": "Комиссия за обработку",
"retrofitFee": "Плата за доработку",
"fobTransportFee": "Транспорт FOB",
"portMiscFee": "Портовые сборы",
"oceanFreight": "Морской фрахт",
"domesticFreight": "Внутренняя перевозка",
"commission": "Комиссия",
"purchaseTax": "Налог на покупку",
"taxRefund": "Налоговый возврат",
"accessoryPrice": "Цена аксессуаров",
"retentionPrice": "Удержание на счете",
"carPrice": "Цена автомобиля",
"increasePrice": "Наценка",
"clearancePrice": "Таможенная цена",
"profit": "Прибыль",
"officialPrice": "Рекомендуемая цена",
"discount": "Скидка",
"listingPrice": "Цена подержанного",
"reduction": "Уценка подержанного",
"totalPrice": "Общая цена"
},
"summary": {
"summaryInfo": "Сводная информация",
"profit": "Прибыль",
"totalProfit": "Общая прибыль"
},
"dates": {
"pickUpDate": "Дата получения",
"collectAllDate": "Дата полного сбора"
},
"otherAttachments": "Другие вложения",
"searchPlaceholder": "Поиск по имени клиента",
"empty": "Нет заказов",
"emptyFinal": "Нет финальных заказов",
"totalRecords": "Всего {{count}} записей",
"confirmDelete": "Вы уверены, что хотите удалить этот заказ?",
"confirmRecall": "Вы уверены, что хотите отозвать этот заказ?",
"confirmVoid": "Вы уверены, что хотите аннулировать этот заказ?",
"confirmSubmit": "Подтвердить отправку?",
"deleteSuccess": "Успешно удалено",
"submitSuccess": "Успешно отправлено",
"recallSuccess": "Успешно отозвано",
"voidSuccess": "Успешно аннулировано",
"saveSuccess": "Успешно сохранено",
"loadFailed": "Ошибка загрузки",
"loadOrderFailed": "Ошибка загрузки данных оригинального заказа",
"loadOrderSuccess": "Данные оригинального заказа загружены",
"saveFailed": "Ошибка сохранения",
"submitFailed": "Ошибка отправки",
"fileUploadSuccess": "Файл успешно загружен!",
"fileUploadFailed": "Ошибка загрузки файла!",
"fileDeleted": "Файл удален",
"uploading": "Загрузка...",
"submitting": "Отправка...",
"atLeastOneItem": "Требуется хотя бы одна позиция",
"atLeastOnePayment": "Требуется хотя бы один платеж",
"deleteFailed": "Ошибка удаления",
"recallFailed": "Ошибка отзыва",
"voidFailed": "Ошибка аннулирования",
"printFailed": "Ошибка печати",
"noPrintFile": "Файл для печати недоступен",
"viewDetails": "Просмотр деталей",
"viewApproval": "Просмотр согласования",
"submitForApproval": "Отправить на согласование",
"recallApproval": "Отозвать согласование",
"loading": "Загрузка...",
"fileN": "Файл {{num}}",
"contractFile": "Файл контракта",
"piFile": "Файл PI",
"transactionSlipFile": "Банковская квитанция",
"fileUpload": {
"contract": "Загрузить контракт",
"piDoc": "Загрузить PI",
"transactionSlip": "Загрузить банковскую квитанцию",
"clickToUpload": "Нажмите для загрузки",
"supportMultiple": "Поддержка загрузки нескольких файлов",
"preview": "Предпросмотр",
"download": "Скачать",
"delete": "Удалить",
"fileTypeAccept": "Разрешены только файлы формата {{accept}}!",
"fileSizeMax": "Размер файла не может превышать {{maxSize}}МБ!"
},
"validation": {
"enterPiNo": "Пожалуйста, введите номер заказа Alibaba/PI",
"selectDate": "Пожалуйста, выберите дату",
"selectClearanceCountry": "Пожалуйста, выберите страну таможенной очистки",
"selectCustomer": "Пожалуйста, выберите клиента",
"enterDeclarant": "Пожалуйста, введите таможенного брокера",
"selectEtaShipDate": "Пожалуйста, выберите ожидаемую дату отправки",
"selectDomesticPort": "Пожалуйста, выберите внутренний порт",
"selectShippingMethod": "Пожалуйста, выберите способ доставки",
"selectTradeTerm": "Пожалуйста, выберите торговые условия",
"selectClearancePort": "Пожалуйста, выберите порт таможенной очистки",
"selectLatestArrival": "Пожалуйста, выберите последнюю дату прибытия",
"selectAllVehicles": "Пожалуйста, выберите транспортные средства для всех моделей",
"uploadContract": "Пожалуйста, загрузите файл контракта",
"uploadPiDoc": "Пожалуйста, загрузите файл PI",
"uploadTransactionSlip": "Пожалуйста, загрузите банковскую квитанцию",
"selectPickUpDate": "Пожалуйста, выберите дату получения",
"selectCollectAllDate": "Пожалуйста, выберите дату полного сбора"
},
"placeholder": {
"autoCalculate": "Автоматический расчет",
"enterPiNo": "Пожалуйста, введите номер заказа Alibaba/PI",
"selectDate": "Пожалуйста, выберите дату",
"selectClearanceCountry": "Пожалуйста, выберите страну таможенной очистки",
"selectCustomer": "Пожалуйста, выберите клиента",
"autoFillCustomerSource": "Автозаполнение после выбора клиента",
"autoFillCustomerCountry": "Автозаполнение после выбора клиента",
"selectVehicle": "Пожалуйста, выберите модель транспортного средства",
"selectExteriorColor": "Пожалуйста, выберите цвет экстерьера",
"selectInteriorColor": "Пожалуйста, выберите цвет интерьера",
"newCarMileage": "0 для нового автомобиля",
"enterVin": "Пожалуйста, введите VIN",
"enterOfficialPrice": "Пожалуйста, введите рекомендованную цену",
"enterListingPrice": "Пожалуйста, введите цену подержанного",
"enterDiscount": "Пожалуйста, введите сумму скидки",
"enterAmount": "Пожалуйста, введите",
"enterIncreasePrice": "Пожалуйста, введите наценку",
"enterUnitPrice": "Пожалуйста, введите цену получения",
"calculatingClearancePrice": "Расчет таможенной цены",
"calculatingCarPrice": "Расчет цены автомобиля",
"calculatingTaxRefund": "Расчет ожидаемого налогового возврата",
"calculatingProfit": "Расчет прибыли",
"enterRemark": "Пожалуйста, введите примечание",
"enterHandlingFee": "Пожалуйста, введите",
"enterContractRate": "Пожалуйста, введите курс обмена по контракту",
"enterDeclarant": "Пожалуйста, введите таможенного брокера",
"selectDomesticPort": "Пожалуйста, выберите внутренний порт",
"selectShippingMethod": "Пожалуйста, выберите способ доставки",
"selectTradeTerm": "Пожалуйста, выберите торговые условия",
"selectClearancePort": "Пожалуйста, выберите порт таможенной очистки",
"enterRemitter": "Пожалуйста, введите отправителя",
"selectRemitCountry": "Пожалуйста, выберите страну отправителя",
"selectAccount": "Пожалуйста, выберите счет",
"enterAmount": "Пожалуйста, введите сумму",
"searchCustomer": "Поиск клиента",
"searchVehicle": "Поиск транспортного средства",
"selectPickUpDate": "Пожалуйста, выберите дату получения",
"selectCollectAllDate": "Пожалуйста, выберите дату полного сбора",
"enterMileage": "Пожалуйста, введите пробег"
},
"entry": {
"edit": "Редактировать заказ",
"copy": "Копировать заказ",
"title": "Ввод заказа",
"baseInfo": "Основная информация",
"baseInfoReadOnly": "Основная информация (только для чтения)",
"orderDetail": "Детали заказа",
"transportInfo": "Детали транспортировки",
"paymentInfo": "Планируемая оплата",
"fileUpload": "Загрузка файлов",
"saveDraft": "Сохранить черновик",
"submitApproval": "Отправить на согласование",
"createFinal": "Создать финальный",
"detail": "Детали",
"totalPrice": "Итого",
"unitPrice": "Цена за единицу",
"carPrice": "Цена автомобиля",
"profit": "Прибыль",
"officialPrice": "Рекомендуемая цена",
"discount": "Скидка",
"listingPrice": "Цена подержанного",
"reduction": "Уценка подержанного",
"add": "Добавить",
"copy": "Копировать"
},
"select": {
"customer": "Выберите клиента",
"vehicle": "Выберите транспортное средство",
"year": "Год",
"msrp": "Рекомендуемая цена"
}
}
}
+273
View File
@@ -186,5 +186,278 @@
"lastMonth": "上月",
"thisYear": "今年",
"lastYear": "去年"
},
"stock": {
"title": "备货单",
"order": "备货单",
"final": "终版备货单",
"entry": "备货录入",
"list": "备货单列表",
"detail": "备货单详情",
"edit": "编辑备货单",
"create": "创建备货单",
"createFinal": "创建终版",
"view": "查看",
"viewFinal": "查看终版",
"orderNo": "备货单号",
"piNo": "阿里订单号/PI号",
"customer": "客户",
"customerName": "客户名称",
"customerSource": "客户来源",
"customerCountry": "客户所在国",
"salesperson": "业务员",
"bizDate": "日期",
"clearanceCountry": "清关目的国",
"remark": "备注",
"approvalHistory": "审批历史",
"contractFile": "合同文件",
"piFile": "PI文件",
"transactionSlipFile": "水单文件",
"vehicle": "车辆",
"fileN": "文件{{num}}",
"totalRecords": "共{{count}}条记录",
"deleteFailed": "删除失败",
"printFailed": "打印失败",
"recallFailed": "撤回失败",
"voidFailed": "作废失败",
"voidSuccess": "作废成功",
"noContractFiles": "暂无合同文件",
"noDetail": "暂无详情",
"noPiFiles": "暂无PI文件",
"noTransactionSlipFiles": "暂无水单文件",
"finalList": "终版列表",
"finalDetail": "终版详情",
"finalDetailTitle": "终版备货单详情",
"viewDetails": "查看详情",
"viewApproval": "查看审批",
"recallApproval": "撤回审批",
"status": {
"draft": "草稿",
"pending": "审批中",
"approved": "已通过",
"rejected": "已拒绝",
"recalled": "已撤回",
"voided": "已作废"
},
"actions": {
"create": "创建",
"edit": "编辑",
"delete": "删除",
"submit": "提交",
"recall": "撤回",
"void": "作废",
"print": "打印",
"copy": "复制",
"view": "查看",
"save": "保存",
"cancel": "取消",
"add": "添加",
"recreate": "重建"
},
"detail": {
"baseInfo": "基础信息",
"orderDetail": "订单明细",
"transportInfo": "运输详情",
"paymentInfo": "计划收款信息",
"attachments": "附件",
"contract": "合同文件",
"piDoc": "PI文件",
"transactionSlip": "水单文件",
"totalCif": "合计到岸价",
"contractRate": "合同汇率"
},
"item": {
"model": "车型",
"exteriorColor": "外饰颜色",
"interiorColor": "内饰颜色",
"mileage": "里程",
"vin": "车架号",
"unitPrice": "收款价",
"clearancePrice": "报关价",
"carPrice": "车价",
"taxRefund": "退税额",
"profit": "利润",
"officialPrice": "官方指导价",
"discount": "优惠",
"listingPrice": "二手挂牌价",
"reduction": "二手减价",
"totalPrice": "总价",
"increasePrice": "加价"
},
"transport": {
"declarant": "清关人",
"etaShipDate": "预计发送日",
"domesticPort": "国内收货港",
"shippingMethod": "运输方式",
"tradeTerm": "贸易条款",
"clearancePort": "清关目的港",
"latestArrival": "最迟到港时间"
},
"dates": {
"pickUpDate": "提车日期",
"collectAllDate": "全部收齐日期"
},
"loading": "加载中...",
"empty": "暂无备货单",
"emptyFinal": "暂无终版备货单",
"confirmDelete": "确定要删除此备货单吗?",
"confirmRecall": "确定要撤回此备货单吗?",
"confirmVoid": "确定要作废此备货单吗?",
"confirmSubmit": "确认提交?",
"deleteSuccess": "删除成功",
"submitSuccess": "提交成功",
"recallSuccess": "撤回成功",
"voidSuccess": "作废成功",
"saveSuccess": "保存成功",
"loadFailed": "加载失败",
"loadOrderFailed": "加载原订单数据失败",
"loadOrderSuccess": "已加载原订单数据",
"saveFailed": "保存失败",
"submitFailed": "提交失败",
"fileUploadSuccess": "文件上传成功!",
"fileUploadFailed": "文件上传失败!",
"fileDeleted": "文件已删除",
"uploading": "上传中...",
"submitting": "提交中...",
"atLeastOneItem": "至少保留一条明细",
"atLeastOnePayment": "至少保留一条收款信息",
"fees": {
"handlingFee": "手续费",
"retrofitFee": "改装费",
"fobTransportFee": "FOB运费",
"portMiscFee": "港杂费",
"oceanFreight": "海运费",
"domesticFreight": "国内运费",
"commission": "佣金",
"purchaseTax": "购置税",
"taxRefund": "退税",
"accessoryPrice": "配件价",
"retentionPrice": "挂账",
"carPrice": "车价",
"increasePrice": "加价",
"clearancePrice": "报关价",
"profit": "利润",
"officialPrice": "官方指导价",
"discount": "优惠额",
"listingPrice": "挂牌价",
"reduction": "二手减价",
"totalPrice": "总价"
},
"payment": {
"remitter": "汇款人",
"remitCountry": "汇款国",
"remitDate": "汇款日期",
"account": "收款账户",
"amount": "金额",
"paymentItem": "收款{{num}}"
},
"summary": {
"summaryInfo": "汇总信息",
"profit": "利润"
},
"searchPlaceholder": "搜索客户名称",
"fileUpload": {
"contract": "上传合同",
"piDoc": "上传PI",
"transactionSlip": "上传水单",
"clickToUpload": "点击上传",
"supportMultiple": "支持多文件上传",
"preview": "预览",
"download": "下载",
"delete": "删除",
"fileTypeAccept": "只能上传{{accept}}格式的文件!",
"fileSizeMax": "文件大小不能超过 {{maxSize}}MB!"
},
"validation": {
"enterPiNo": "请输入阿里订单号/PI号",
"selectDate": "请选择日期",
"selectClearanceCountry": "请选择清关目的国",
"selectCustomer": "请选择客户",
"enterDeclarant": "请输入清关人",
"selectEtaShipDate": "请选择预计发送日",
"selectDomesticPort": "请选择国内收货港",
"selectShippingMethod": "请选择运输方式",
"selectTradeTerm": "请选择贸易条款",
"selectClearancePort": "请选择清关目的港",
"selectLatestArrival": "请选择最迟到港时间",
"selectAllVehicles": "请选择所有车型的车辆",
"uploadContract": "请上传合同文件",
"uploadPiDoc": "请上传PI文件",
"uploadTransactionSlip": "请上传水单文件",
"selectPickUpDate": "请选择提车日期",
"selectCollectAllDate": "请选择全部收齐日期"
},
"placeholder": {
"autoCalculate": "自动计算",
"enterPiNo": "请输入阿里订单号/PI号",
"selectDate": "请选择日期",
"selectClearanceCountry": "请选择清关目的国",
"selectCustomer": "请选择客户",
"autoFillCustomerSource": "选择客户后自动填充",
"autoFillCustomerCountry": "选择客户后自动填充",
"selectVehicle": "请选择车型",
"selectExteriorColor": "请选择外饰颜色",
"selectInteriorColor": "请选择内饰颜色",
"newCarMileage": "新车为0",
"enterVin": "请输入车架号",
"enterOfficialPrice": "请输入官方指导价",
"enterListingPrice": "请输入二手挂牌价",
"enterDiscount": "请输入优惠额",
"enterAmount": "请输入",
"enterIncreasePrice": "请输入加价",
"enterUnitPrice": "请输入收款价",
"calculatingClearancePrice": "正在计算报关价",
"calculatingCarPrice": "正在计算车价",
"calculatingTaxRefund": "正在计算预估退税额",
"calculatingProfit": "正在计算利润",
"enterRemark": "请输入备注",
"enterHandlingFee": "请输入",
"enterContractRate": "请输入合同汇率",
"enterDeclarant": "请输入清关人",
"selectDomesticPort": "请选择国内收货港",
"selectShippingMethod": "请选择运输方式",
"selectTradeTerm": "请选择贸易条款",
"selectClearancePort": "请选择清关目的港",
"enterRemitter": "请输入汇款人",
"selectRemitCountry": "请选择汇款国",
"selectAccount": "请选择账户",
"enterAmount": "请输入金额",
"searchCustomer": "搜索客户",
"searchVehicle": "搜索车型",
"selectPickUpDate": "请选择提车日期",
"selectCollectAllDate": "请选择全部收齐日期",
"enterMileage": "请输入里程"
},
"entry": {
"edit": "编辑备货单",
"copy": "复制备货单",
"title": "备货录入",
"baseInfo": "基础信息",
"baseInfoReadOnly": "基础信息(只读)",
"orderDetail": "订单明细",
"transportInfo": "运输详情",
"paymentInfo": "计划收款",
"fileUpload": "文件上传",
"saveDraft": "保存草稿",
"submitApproval": "提交审批",
"createFinal": "创建终版",
"detail": "详情",
"totalPrice": "合计",
"unitPrice": "单价",
"carPrice": "车价",
"profit": "利润",
"officialPrice": "官方指导价",
"discount": "优惠额",
"listingPrice": "挂牌价",
"reduction": "二手减价",
"add": "添加",
"copy": "复制"
},
"select": {
"customer": "选择客户",
"vehicle": "选择车型",
"year": "年款",
"msrp": "指导价"
}
}
}
+3 -5
View File
@@ -27,9 +27,7 @@ function App() {
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
)
+1 -29
View File
@@ -67,16 +67,6 @@ export default function ApprovalList() {
const currentTab = tab || activeTab
console.log('🔍 Loading approval list START:', {
requestId,
currentTab,
activeTab,
tab,
pageNum,
isRefresh,
requestIdRefNow: requestIdRef.current
})
setLoading(true)
try {
@@ -86,31 +76,19 @@ export default function ApprovalList() {
page_size: 20,
})
console.log('📦 API response received:', {
requestId,
currentTab,
requestIdRefNow: requestIdRef.current,
count: result?.list?.length
})
// 检查是否是最新的请求
if (requestIdRef.current !== requestId) {
console.log('⏭️ Skipping outdated response:', {
requestId,
latestRequestId: requestIdRef.current
})
return
}
const newList = result?.list || []
// 直接更新数据
console.log('✅ Updating list for tab:', currentTab, 'items:', newList.length)
setList(newList)
setHasMore(newList.length >= 20)
setPage(pageNum)
} catch (error: any) {
console.error('❌ Failed to load approvals:', error)
// 静默处理错误
} finally {
setLoading(false)
}
@@ -118,7 +96,6 @@ export default function ApprovalList() {
// 切换标签
const handleTabChange = (key: string) => {
console.log('🔄 Tab changing from', activeTab, 'to', key)
setActiveTab(key as 'my' | 'pending' | 'approved' | 'cc')
// 保存 tab 到 sessionStorage
sessionStorage.setItem('approval-tab', key)
@@ -141,12 +118,7 @@ export default function ApprovalList() {
// 初始加载
useEffect(() => {
console.log('🚀 Initial load effect')
loadData(1, true)
return () => {
console.log('🧹 Cleanup function called')
}
}, [])
// 查看详情
+4 -2
View File
@@ -33,8 +33,10 @@ interface ActivityItem {
// 从应用页面迁移的完整应用列表
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: '2', icon: '📦', label: '备货单', path: '/stock/order', color: '#52c41a', description: '备货单管理' },
{ id: '3', icon: '📋', label: '终版备货单', path: '/stock/final', color: '#722ed1', description: '终版备货单管理' },
{ id: '4', icon: '📝', label: '采购申请', path: '/purchase/apply', color: '#13c2c2', description: '物品采购审批', disabled: true },
{ id: '5', icon: '📥', label: '备货录入', path: '/stock/entry', color: '#faad14', description: '库存备货录入' },
]
const allApps: QuickAction[] = [
+28 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Input, Toast } from 'antd-mobile'
import { useUserStore } from '@/stores/user'
import { fdeskApiObj } from '@/services/api'
import styles from './login-simple.module.css'
export default function SimpleLogin() {
@@ -30,9 +31,35 @@ export default function SimpleLogin() {
const result = await response.json()
if (result.code === 0) {
const { user, token } = result.data
// 登录成功,保存到 store
login(result.data.user, result.data.token)
login(user, token)
localStorage.setItem('i18nextLng', 'zh')
// 获取用户系统映射,然后获取 fdesk 系统的独立 token
if (user.company?.abbr) {
fdeskApiObj.getSystemMapping()
.then((mappingResult: any) => {
// api 响应拦截器返回 data.data,所以直接访问 system_user_id
if (mappingResult?.system_user_id) {
return fdeskApiObj.getSystemToken({
system_user_id: mappingResult.system_user_id,
corp_abbr: user.company.abbr,
})
}
return Promise.resolve(null)
})
.then((fdeskResult: any) => {
if (fdeskResult?.access_token) {
const { setFdeskToken } = useUserStore.getState()
setFdeskToken(fdeskResult.access_token)
}
})
.catch(() => {
// 不阻塞登录流程,继续执行
})
}
Toast.show('登录成功')
// 跳转到首页
navigate('/dashboard')
+28 -5
View File
@@ -2,6 +2,8 @@ import { useState, useEffect, useRef } from 'react'
import { Dialog, Toast } from 'antd-mobile'
import { QRCodeSVG } from 'qrcode.react'
import { useNavigate } from 'react-router-dom'
import { useUserStore } from '@/stores/user'
import { fdeskApiObj } from '@/services/api'
import styles from './QRCodeLogin.module.css'
// 声明飞书 QR SDK 类型
@@ -247,25 +249,46 @@ export default function QRCodeLogin({ visible, onClose }: QRCodeLoginProps) {
if (result.code === 0) {
if (result.data.status === 'success') {
console.log('✅ Login success detected, navigating to dashboard...')
setStatus('success')
// 登录成功,保存 token
const { token, user } = result.data
localStorage.setItem('token', token)
localStorage.setItem('i18nextLng', 'zh')
// 触发登录成功事件,让 store 更新
window.dispatchEvent(new CustomEvent('feishu-login-success', { detail: { user, token } }))
// 更新 store
const { login, setFdeskToken } = useUserStore.getState()
login(user, token)
// 获取用户系统映射,然后获取 fdesk 系统的独立 token
if (user.company?.abbr) {
fdeskApiObj.getSystemMapping()
.then((mappingResult: any) => {
// api 响应拦截器返回 data.data,所以直接访问 system_user_id
if (mappingResult?.system_user_id) {
return fdeskApiObj.getSystemToken({
system_user_id: mappingResult.system_user_id,
corp_abbr: user.company.abbr,
})
}
return Promise.resolve(null)
})
.then((fdeskResult: any) => {
if (fdeskResult?.access_token) {
setFdeskToken(fdeskResult.access_token)
}
})
.catch(() => {
// 不阻塞登录流程,继续执行
})
}
Toast.show('登录成功')
setTimeout(() => {
console.log('🚀 Navigating...')
onClose()
// 检查是否有 redirect 参数
const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect')
if (redirect) {
console.log('🔗 登录成功,跳转到:', redirect)
window.location.href = redirect
} else {
navigate('/dashboard')
+28 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Toast } from 'antd-mobile'
import { useUserStore } from '@/stores/user'
import { fdeskApiObj } from '@/services/api'
import QRCodeLogin from './QRCodeLogin'
import styles from './login.module.css'
@@ -219,7 +220,33 @@ export default function Login() {
const result = await response.json()
if (result.code === 0) {
login(result.data.user, result.data.token)
const { user, token } = result.data
login(user, token)
// 获取用户系统映射,然后获取 fdesk 系统的独立 token
if (user.company?.abbr) {
fdeskApiObj.getSystemMapping()
.then((mappingResult: any) => {
// api 响应拦截器返回 data.data,所以直接访问 system_user_id
if (mappingResult?.system_user_id) {
return fdeskApiObj.getSystemToken({
system_user_id: mappingResult.system_user_id,
corp_abbr: user.company.abbr,
})
}
return Promise.resolve(null)
})
.then((fdeskResult: any) => {
if (fdeskResult?.access_token) {
const { setFdeskToken } = useUserStore.getState()
setFdeskToken(fdeskResult.access_token)
}
})
.catch(() => {
// 不阻塞登录流程,继续执行
})
}
Toast.show('登录成功')
const urlParams = new URLSearchParams(window.location.search)
+16
View File
@@ -17,6 +17,14 @@ import Login from '@/pages/login'
import SimpleLogin from '@/pages/login-simple'
import DebugFeishu from '@/pages/debug-feishu'
import FeishuEnvTest from '@/pages/feishu-env-test'
import StockEntry from '@/pages/stock/entry'
import StockOrder from '@/pages/stock/order'
import StockShow from '@/pages/stock/show'
import StockEdit from '@/pages/stock/edit'
import StockFinal from '@/pages/stock/final'
import StockFinalShow from '@/pages/stock/final-show'
import StockFinalEdit from '@/pages/stock/final-edit'
import StockFinalEntry from '@/pages/stock/final-entry'
const router = createBrowserRouter([
{
@@ -36,6 +44,14 @@ const router = createBrowserRouter([
{ path: 'business-trip/:id', element: <BusinessTripDetail /> },
{ path: 'approval', element: <ApprovalList /> },
{ path: 'approval/:id', element: <ApprovalDetail /> },
{ path: 'stock/entry', element: <StockEntry /> },
{ path: 'stock/order', element: <StockOrder /> },
{ path: 'stock/show', element: <StockShow /> },
{ path: 'stock/edit', element: <StockEdit /> },
{ path: 'stock/final', element: <StockFinal /> },
{ path: 'stock/final-show', element: <StockFinalShow /> },
{ path: 'stock/final-edit', element: <StockFinalEdit /> },
{ path: 'stock/final-entry', element: <StockFinalEntry /> },
],
},
// 详情页移到顶层,支持未登录访问(页面自己处理登录检查)
+212
View File
@@ -1,5 +1,6 @@
import axios from 'axios'
import { Toast } from 'antd-mobile'
import HmacMD5 from 'crypto-js/hmac-md5'
// 声明飞书 JSAPI 类型
declare global {
@@ -42,6 +43,9 @@ export interface LoginResponse {
const API_BASE = '/api/v1'
// ========== Fdesk API 配置 ==========
const FDESK_BASE = 'https://fdesk.hnxinyouhe.com/'
// 标记是否正在刷新 token
let isRefreshing = false
let failedQueue: Array<{
@@ -240,6 +244,89 @@ const api = axios.create({
},
})
// 创建 fdesk axios 实例(独立的一套请求系统)
const fdeskApi = axios.create({
baseURL: FDESK_BASE,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 获取公司标识的辅助函数
// zustand persist 存储结构: { state: { user, currentCompany, token, isLoggedIn }, version: 0 }
const getCompanyAbbr = (): string | undefined => {
try {
const userStorage = localStorage.getItem('user-storage')
if (userStorage) {
const stored = JSON.parse(userStorage)
// zustand persist 存储在 state 对象下
const abbr = stored.state?.currentCompany?.abbr
console.log('🔍 [getCompanyAbbr] abbr:', abbr)
return abbr
}
} catch (e) {
console.error('❌ [getCompanyAbbr] 获取公司标识失败:', e)
}
return undefined
}
// fdesk 请求拦截器:添加特殊请求头
fdeskApi.interceptors.request.use(
(config) => {
// 使用独立的 fdesk_token(不是本地登录的 token)
const fdeskToken = localStorage.getItem('fdesk_token')
if (fdeskToken) {
config.headers['Fdesk-Auth-Token'] = `Bearer ${fdeskToken}`
}
// 添加公司标识
const companyAbbr = getCompanyAbbr()
if (companyAbbr) {
config.headers['Fdesk-Corp-Abbr'] = companyAbbr
}
// 添加语言头
const lang = localStorage.getItem('i18nextLng') || 'zh'
config.headers['Fdesk-Language'] = lang
// 固定头
config.headers['Fdesk-Login-Type'] = 'feishu'
config.headers['Fdesk-System-Code'] = 'fdesk'
return config
},
(error) => {
return Promise.reject(error)
}
)
// fdesk 响应拦截器:处理错误
// 参考项目使用 code: 200 表示成功,同时兼容 code: 0 和直接返回数据的情况
fdeskApi.interceptors.response.use(
(response) => {
const { data } = response
// 情况1: 标准 fdesk 响应 { code: 200, data: {...} }
if (data.code === 200) {
return data.data !== undefined ? data.data : data
}
// 情况2: 兼容 code: 0 的响应
if (data.code === 0 || data.success === true) {
return data.data !== undefined ? data.data : data
}
// 情况3: 直接返回数据(没有 code 字段),如数组等
if (data.code === undefined) {
return data
}
// 错误响应
return Promise.reject(new Error(data.message || data.msg || '请求失败'))
},
async (error) => {
const message = error.response?.data?.message || error.response?.data?.msg || error.message || '网络错误'
return Promise.reject(new Error(message))
}
)
// 请求拦截器:添加 token 和语言
api.interceptors.request.use(
(config) => {
@@ -401,4 +488,129 @@ export const approvalApi = {
cancel: (id: number) => api.post(`/approval/cancel/${id}`),
}
// ========== Fdesk API ==========
// Fdesk API 接口(使用独立的请求头和 baseURL)
export const fdeskApiObj = {
// 示例:获取用户信息
getUserInfo: () => fdeskApi.get<any, any>('/api/user/info'),
// 示例:获取列表
getList: (params?: any) => fdeskApi.get<any, any>('/api/list', { params }),
// 示例:创建数据
create: (data: any) => fdeskApi.post<any, any>('/api/create', data),
// ========== 备货单 API ==========
// 备货单列表
stockingList: (params?: any) => fdeskApi.get<any, any>('/crm/stock/order/list', { params }),
// 备货单详情
stockingDetail: (id: string | number) => fdeskApi.get<any, any>('/crm/stock/order/detail', { params: { id } }),
stockingShow: (id: string | number) => fdeskApi.get<any, any>('/crm/stock/order/show', { params: { id } }),
checkStock: (data: { id: string | number }) => fdeskApi.get<any, any>('/crm/stock/order/check', { params: data }),
// 添加备货单
stockingAdd: (data: unknown) => fdeskApi.post<any, any>('/crm/stock/order/store', data),
// 编辑备货单
stockingUpdate: (data: any) => fdeskApi.post<any, any>('/crm/stock/order/update', data),
// 提交备货单审批
stockingSubmit: (data: any) => fdeskApi.post<any, any>('/crm/stock/order/approval', data),
// 备货单打印
stockingPrint: (id: string | number) => fdeskApi.get<any, any>('/crm/stock/order/print', { params: { id } }),
// 备货单审批记录
stockingApprovalRecords: (params?: any) => fdeskApi.get<any, any>('/crm/approval/records/todo', { params }),
// 备货单审批通过/拒绝
stockingApprovalAction: (data: any) => fdeskApi.post<any, any>('/crm/approval/records/action', data),
// 备货单审批历史
stockingApprovalLogs: (params?: any) => fdeskApi.get<any, any>('/crm/approval/records/logs', { params }),
// 终极备货单提交
stockingUltimateApproval: (data: any) => fdeskApi.post<any, any>('/crm/stock/order/ultimate/approval', data),
// 备货单撤回
stockingRecall: (data: any) => fdeskApi.post<any, any>('/crm/stock/order/revoke/approval', data),
// 备货单作废
stockingInvalid: (data: any) => fdeskApi.post<any, any>('/crm/stock/order/nullify/approval', data),
// 备货单删除
stockingDelete: (data: any) => fdeskApi.post<any, any>('/crm/stock/order/destroy', data),
// 获取客户列表(分页)
getCustomerList: (params?: any) => fdeskApi.get<any, any>('/crm/customer/list', { params }),
// 获取国家列表
getCountryList: () => fdeskApi.get<any, any>('/crm/customer/getCountry'),
// 获取渠道列表
getChannelList: () => fdeskApi.get<any, any>('/crm/customer/getChannel'),
// 获取港口下拉选项(用于表单选择)
getPortSelect: (params?: any) => fdeskApi.get<any, any>('/common/preset/port', { params }),
// 获取车型列表(分页,用于选择车型)
getVehicleList: (params?: any) => fdeskApi.get<any, any>('/crm/vehicle/sku/list', { params }),
// 获取币种列表
getCurrencyList: () => fdeskApi.get<any, any>('/common/currency/list'),
// 获取币种下拉选项(用于表单选择)
getCurrencySelect: () => fdeskApi.get<any, any>('/common/preset/currency'),
// 获取账户下拉选项(用于表单选择)
getAccountSelect: () => fdeskApi.get<any, any>('/common/preset/account'),
// 获取颜色下拉选项(type: 1=外饰, 2=内饰)
getColorSelect: (params: any) => fdeskApi.get<any, any>('/common/preset/color', { params }),
// 获取字典数据(运输方式、贸易条款等)
// scope: '2,15' -> 2=运输方式(transport), 15=贸易条款(trade_terms)
getDictSelect: (params?: any) => fdeskApi.get<any, any>('/admin/dict/scope', { params }),
// 获取运输方式列表(使用 getDictSelect({ scope: '2,15' }) 返回的 transport 字段)
getTransportList: () => fdeskApi.get<any, any>('/admin/dict/scope', { params: { scope: '2,15' } }),
// 获取贸易条款列表(使用 getDictSelect({ scope: '2,15' }) 返回的 trade_terms 字段)
getTradeTermList: () => fdeskApi.get<any, any>('/admin/dict/scope', { params: { scope: '2,15' } }),
// 上传文件
uploadFile: (file: File, scene: string = 'stock_orders') => {
const formData = new FormData()
formData.append('file', file)
formData.append('scene', scene)
return fdeskApi.post<any, any>('/ticket/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
// 获取审批时间线(用于显示审批历史)
stockingApprovalTimeline: (params?: any) => fdeskApi.get<any, any>('/admin/approval/records/timeline', { params }),
// ========== Fdesk 系统 Token API ==========
// 获取用户系统映射(返回 system_user_id)
getSystemMapping: () => api.get<any, any>('/user/system-mapping', { params: { system: 'fdesk' } }),
// 获取 fdesk 系统的独立 token
getSystemToken: (params: { system_user_id: string; corp_abbr: string }) => {
const timestamp = Math.floor(Date.now() / 1000)
// 使用 HMAC-MD5 算法生成签名
const signature = HmacMD5(`${params.system_user_id}${timestamp}`, 'a7H9kL2pQ5sW8xZ1').toString()
return fdeskApi.post<any, any>('/api/system/auth', {
user_id: params.system_user_id,
timestamp,
signature,
system_code: 'fdesk',
corp_abbr: params.corp_abbr,
})
}
}
export { fdeskApi }
export default api
+16 -2
View File
@@ -7,6 +7,7 @@ interface UserState {
token: string | null
currentCompany: Company | null
isLoggedIn: boolean
fdeskToken: string | null // Fdesk 系统的独立 token
// Actions
setUser: (user: User | null) => void
@@ -14,6 +15,7 @@ interface UserState {
setCurrentCompany: (company: Company | null) => void
login: (user: User, token: string) => void
logout: () => void
setFdeskToken: (token: string | null) => void
}
export const useUserStore = create<UserState>()(
@@ -23,6 +25,7 @@ export const useUserStore = create<UserState>()(
token: null,
currentCompany: null,
isLoggedIn: false,
fdeskToken: null,
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
@@ -30,12 +33,22 @@ export const useUserStore = create<UserState>()(
login: (user, token) => {
localStorage.setItem('token', token)
set({ user, token, isLoggedIn: true })
set({ user, token, isLoggedIn: true, currentCompany: user.company || null })
},
logout: () => {
localStorage.removeItem('token')
set({ user: null, token: null, currentCompany: null, isLoggedIn: false })
localStorage.removeItem('fdesk_token')
set({ user: null, token: null, currentCompany: null, isLoggedIn: false, fdeskToken: null })
},
setFdeskToken: (token) => {
if (token) {
localStorage.setItem('fdesk_token', token)
} else {
localStorage.removeItem('fdesk_token')
}
set({ fdeskToken: token })
},
}),
{
@@ -45,6 +58,7 @@ export const useUserStore = create<UserState>()(
token: state.token,
currentCompany: state.currentCompany,
isLoggedIn: state.isLoggedIn,
fdeskToken: state.fdeskToken,
}),
}
)
+5
View File
@@ -24,6 +24,7 @@ export type ApprovalNodeType = (typeof ApprovalNodeType)[keyof typeof ApprovalNo
export interface User {
id: number
company_id: number
company?: Company // 用户所属公司信息(登录时返回)
sn?: string
name: string
email?: string
@@ -38,7 +39,11 @@ export interface User {
export interface Company {
id: string
name: string
abbr?: string // 公司标识,用于 fdesk API 请求头 Fdesk-Corp-Abbr
tenant_key?: string
feishuAppId?: string
feishu_enabled?: boolean
sort?: number
status: number
}