feat: add used-car module and fix API response handling
- Add used-car pages: list, detail, create, edit, mine - Add CarCard and PriceModal components - Fix API response interceptor to accept both code 200 and 0 - Fix detail page: was incorrectly using edit page template - Fix edit page: remove res.code check after API call - Add image URL processing (localhost → localhost:8787) - Add i18n translations for used-car module in 5 languages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,718 @@
|
||||
.createPage {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f8;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.createContent {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
/* 表单卡片 */
|
||||
.formCard {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 表单单元格 */
|
||||
.formCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.formCell:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.formCellVertical {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.formCellVertical .formLabel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 输入框样式 */
|
||||
.admInputWrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admInputWrapper :global(.adm-input) {
|
||||
--font-size: 15px;
|
||||
--color-text: #333;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admInputWrapper :global(.adm-input::placeholder) {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 产品名称 + 绑定按钮 */
|
||||
.inputWithAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputWithAction .admInputWrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bindBtn {
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: #2E6CF6;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bindBtn:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 绑定提示 */
|
||||
.bindTip {
|
||||
padding: 10px 16px;
|
||||
background: #F3F6FF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
margin: 0 0px 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.bindTipText {
|
||||
color: #2B3A55;
|
||||
}
|
||||
|
||||
.bindTipClear {
|
||||
color: #2E6CF6;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 选择器触发器 */
|
||||
.pickerTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 24px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pickerTrigger::after {
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #bbb;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.pickerValue {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pickerPlaceholder {
|
||||
font-size: 15px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 表单区域 */
|
||||
.formSection {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.formSectionLabel {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.imageUploadArea {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.attachmentArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.uploadBtn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
background: #2E6CF6;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.uploadBtn:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.uploadBtn:disabled {
|
||||
opacity: 0.5;
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.attachmentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachmentItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.attachmentName {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachmentDelete {
|
||||
color: #ff4d4f;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachmentDelete:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.attachmentHint {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
/* 提交栏 */
|
||||
.submitBar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background: #3F435B;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(63, 67, 91, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submitBtn:active {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ========== 绑定弹窗样式 ========== */
|
||||
|
||||
/* 遮罩层 */
|
||||
:global(.adm-modal-mask) {
|
||||
background: rgba(0, 0, 0, 0.45) !important;
|
||||
}
|
||||
|
||||
/* 弹窗容器 */
|
||||
:global(.adm-modal-body) {
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden !important;
|
||||
max-width: 420px !important;
|
||||
/* width: 92% !important; */
|
||||
max-height: 80vh !important;
|
||||
}
|
||||
|
||||
:global(.adm-modal-content) {
|
||||
padding: 0 !important;
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden !important;
|
||||
max-height: 80vh !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* 弹窗内容区域 */
|
||||
.bindDialogContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 弹窗头部 */
|
||||
.bindDialogHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bindDialogTitle {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.bindDialogClose {
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
margin: -4px -8px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bindDialogClose:hover {
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.bindDialogClose:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* 品牌/车系信息区 */
|
||||
.bindBrandInfo {
|
||||
padding: 14px 20px;
|
||||
background: linear-gradient(135deg, #F8FAFC 0%, #F3F6FF 100%);
|
||||
border-bottom: 1px solid #e8ecf0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bindBrandInfoLabel {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bindBrandInfoValue {
|
||||
font-size: 15px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bindBrandInfoSeparator {
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 搜索框区域 */
|
||||
.bindSearchArea {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bindSearch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: #F8FAFC;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bindSearch:focus-within {
|
||||
background: #fff;
|
||||
border-color: #2E6CF6;
|
||||
box-shadow: 0 0 0 3px rgba(46, 108, 246, 0.1);
|
||||
}
|
||||
|
||||
.bindSearch :global(.adm-input) {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
--font-size: 15px;
|
||||
--color-text: #333;
|
||||
}
|
||||
|
||||
.bindSearch :global(.adm-input::placeholder) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.bindSearchClear {
|
||||
color: #2E6CF6;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bindSearchClear:active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* 车型列表区域 */
|
||||
.bindList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 16px;
|
||||
background: #f8fafc;
|
||||
min-height: 200px;
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.bindList::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.bindList::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bindList::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 加载和空状态 */
|
||||
.bindLoading,
|
||||
.bindEmpty {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bindLoading::before {
|
||||
content: '';
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #2E6CF6;
|
||||
border-radius: 50%;
|
||||
animation: bindSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bindSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 车型列表项 */
|
||||
.bindItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: #fff;
|
||||
border: 2px solid transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bindItem::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(46, 108, 246, 0.05) 0%, rgba(46, 108, 246, 0.02) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.bindItem:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.bindItem.active {
|
||||
background: linear-gradient(135deg, #F0F5FF 0%, #E8F0FF 100%);
|
||||
border-color: #2E6CF6;
|
||||
box-shadow: 0 4px 12px rgba(46, 108, 246, 0.15);
|
||||
}
|
||||
|
||||
.bindItem.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bindItemMain {
|
||||
flex: 1;
|
||||
padding-right: 16px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bindItemTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemTitle {
|
||||
color: #2E6CF6;
|
||||
}
|
||||
|
||||
.bindItemSub {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bindItemSub::before {
|
||||
content: '¥';
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemSub {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* 单选按钮样式 */
|
||||
.bindItemRadio {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemRadio {
|
||||
border-color: #2E6CF6;
|
||||
background: #2E6CF6;
|
||||
box-shadow: 0 2px 8px rgba(46, 108, 246, 0.3);
|
||||
}
|
||||
|
||||
.bindItemRadioDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transform: scale(0);
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemRadioDot {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* 底部按钮区 - 固定在底部 */
|
||||
.bindDialogFooter {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.bindCancelBtn,
|
||||
.bindConfirmBtn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.bindCancelBtn {
|
||||
background: #f5f6f8;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.bindCancelBtn:active {
|
||||
background: #e8eaed;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.bindConfirmBtn {
|
||||
background: linear-gradient(135deg, #2E6CF6 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(46, 108, 246, 0.25);
|
||||
}
|
||||
|
||||
.bindConfirmBtn:active {
|
||||
transform: scale(0.97);
|
||||
box-shadow: 0 2px 6px rgba(46, 108, 246, 0.25);
|
||||
}
|
||||
|
||||
.bindConfirmBtn:disabled {
|
||||
opacity: 0.5;
|
||||
background: #e2e8f0;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bindConfirmBtn:disabled:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 图片上传区域 */
|
||||
.admImageUploader {
|
||||
--cell-size: 80px;
|
||||
}
|
||||
|
||||
.admImageUploader :global(.adm-image-uploader-cell-mask) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* TextArea 样式 */
|
||||
.admTextAreaWrapper :global(.adm-text-area) {
|
||||
--font-size: 15px;
|
||||
--color-text: #333;
|
||||
background: #f9f9f9;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 兼容旧的 checkDot 类名 */
|
||||
.checkDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transform: scale(0);
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* 兼容旧的 bindItemCheck 类名 */
|
||||
.bindItemCheck {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bindItem.active .bindItemCheck {
|
||||
border-color: #2E6CF6;
|
||||
background: #2E6CF6;
|
||||
box-shadow: 0 2px 8px rgba(46, 108, 246, 0.3);
|
||||
}
|
||||
|
||||
.bindItem.active .checkDot {
|
||||
transform: scale(1);
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Input,
|
||||
Button,
|
||||
DatePicker,
|
||||
Toast,
|
||||
TextArea,
|
||||
ImageUploader,
|
||||
Modal
|
||||
} from 'antd-mobile'
|
||||
import { uploadApi, usedCarApi } from '@/services/api'
|
||||
import styles from './create.module.css'
|
||||
|
||||
// 车型接口
|
||||
interface CarModel {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
msrp: number
|
||||
}
|
||||
|
||||
// 关键词搜索结果接口
|
||||
interface KeywordSearchResult {
|
||||
brand: string
|
||||
series: string
|
||||
series_id: string
|
||||
models: CarModel[]
|
||||
}
|
||||
|
||||
// 绑定状态接口
|
||||
interface BoundProduct {
|
||||
brand?: string
|
||||
series?: string
|
||||
series_id?: string
|
||||
model?: CarModel
|
||||
}
|
||||
|
||||
// 图片文件项接口
|
||||
interface ImageFileItem {
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
const UsedCarCreate: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({
|
||||
product_name: '',
|
||||
vin: '',
|
||||
price: '',
|
||||
source_place: '',
|
||||
plate_place: '',
|
||||
plate_time: '',
|
||||
displacement: '',
|
||||
power: '',
|
||||
body_color: '',
|
||||
condition_desc: '',
|
||||
mileage: '',
|
||||
// 绑定车型数据字段
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
|
||||
// 图片和附件
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [attachments, setAttachments] = useState<Array<{ id: number; name: string; url: string; size: number }>>([])
|
||||
|
||||
// 绑定弹窗状态
|
||||
const [bindVisible, setBindVisible] = useState(false)
|
||||
const [bindLoading, setBindLoading] = useState(false)
|
||||
const [bindResult, setBindResult] = useState<KeywordSearchResult | null>(null)
|
||||
const [bindSelected, setBindSelected] = useState<CarModel | null>(null)
|
||||
const [boundProduct, setBoundProduct] = useState<BoundProduct | null>(null)
|
||||
const [bindKeyword, setBindKeyword] = useState('')
|
||||
|
||||
// 提交中
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 上牌时间选择
|
||||
const [plateTimeVisible, setPlateTimeVisible] = useState(false)
|
||||
|
||||
// 产品名称绑定(关键词搜索)
|
||||
const handleBindByName = async () => {
|
||||
const name = formData.product_name.trim()
|
||||
if (!name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setBindVisible(true)
|
||||
setBindLoading(true)
|
||||
setBindResult(null)
|
||||
setBindSelected(null)
|
||||
setBindKeyword('')
|
||||
|
||||
try {
|
||||
const res = await usedCarApi.crawlKeyword(name)
|
||||
setBindResult(res)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.bindFetchFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setBindLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认绑定
|
||||
const handleConfirmBind = () => {
|
||||
if (!bindSelected || !bindResult) return
|
||||
|
||||
setBoundProduct({
|
||||
brand: bindResult.brand,
|
||||
series: bindResult.series,
|
||||
series_id: bindResult.series_id,
|
||||
model: bindSelected
|
||||
})
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: bindResult.brand,
|
||||
crawl_series: bindResult.series,
|
||||
crawl_series_id: bindResult.series_id,
|
||||
crawl_model_name: bindSelected.name,
|
||||
crawl_model_url: bindSelected.url,
|
||||
crawl_msrp: bindSelected.msrp ? String(bindSelected.msrp) : ''
|
||||
})
|
||||
|
||||
setBindVisible(false)
|
||||
Toast.show({ content: t('usedCar.bindSuccess'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 清除绑定
|
||||
const handleClearBind = () => {
|
||||
setBoundProduct(null)
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
Toast.show({ content: t('usedCar.bindCleared'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 过滤车型列表
|
||||
const filteredModels = bindResult?.models.filter((model) => {
|
||||
const kw = bindKeyword.toLowerCase().trim()
|
||||
if (!kw) return true
|
||||
const name = String(model.name || '').toLowerCase()
|
||||
return name.includes(kw)
|
||||
}) || []
|
||||
|
||||
// 选择车型
|
||||
const handleSelectModel = (model: CarModel) => {
|
||||
setBindSelected(model)
|
||||
}
|
||||
|
||||
// 跟踪上传数量
|
||||
const [uploadingCount, setUploadingCount] = useState(0)
|
||||
|
||||
// 图片上传处理(在 ImageUploader 的 upload prop 中调用)
|
||||
const handleImageUpload = async (file: File): Promise<{ url: string }> => {
|
||||
setUploadingCount(prev => prev + 1)
|
||||
try {
|
||||
const result: any = await uploadApi.uploadUsedCarImage(file)
|
||||
return { url: result.full_url || result.url }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
} finally {
|
||||
setUploadingCount(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片变化处理
|
||||
const handleImageChange = (newFiles: ImageFileItem[]) => {
|
||||
// 更新图片列表
|
||||
setImages(newFiles.map(f => f.url))
|
||||
}
|
||||
|
||||
// 图片删除处理
|
||||
const handleImageDelete = (deletedFile: ImageFileItem) => {
|
||||
const newImages = images.filter(url => url !== deletedFile.url)
|
||||
setImages(newImages)
|
||||
}
|
||||
|
||||
// 附件上传处理
|
||||
const handleAttachmentUpload = async (file: File): Promise<{ id: number; name: string; url: string; size: number }> => {
|
||||
try {
|
||||
const result: any = await uploadApi.uploadFile(file, 'other', 'usedcar')
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.file_name || file.name,
|
||||
url: result.url,
|
||||
size: result.file_size || file.size
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 附件选择处理
|
||||
const handleAttachmentSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const newAttachments = [...attachments]
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (newAttachments.length + i >= 20) {
|
||||
Toast.show({ content: t('usedCar.maxAttachments'), icon: 'fail' })
|
||||
break
|
||||
}
|
||||
try {
|
||||
const result = await handleAttachmentUpload(file)
|
||||
newAttachments.push(result)
|
||||
} catch (err) {
|
||||
// 上传失败已在 handleAttachmentUpload 中处理
|
||||
}
|
||||
}
|
||||
setAttachments(newAttachments)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 附件删除处理
|
||||
const handleAttachmentDelete = (index: number) => {
|
||||
const newAttachments = attachments.filter((_, i) => i !== index)
|
||||
setAttachments(newAttachments)
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.product_name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
// 检查是否有正在上传的图片
|
||||
if (uploadingCount > 0) {
|
||||
return Toast.show({ content: t('usedCar.uploading'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
price: formData.price ? Number(formData.price) : undefined,
|
||||
mileage: formData.mileage ? Number(formData.mileage) : undefined,
|
||||
crawl_msrp: formData.crawl_msrp ? Number(formData.crawl_msrp) : undefined,
|
||||
// 图片数组转为逗号分隔字符串
|
||||
images: images.join(','),
|
||||
// 附件数组转为JSON
|
||||
attachment: attachments,
|
||||
}
|
||||
|
||||
const id = await usedCarApi.create(payload)
|
||||
if (id) {
|
||||
Toast.show({ content: t('usedCar.createSuccess'), icon: 'success' })
|
||||
setTimeout(() => {
|
||||
navigate('/used-car')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('common.error')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.createPage}>
|
||||
{/* <NavBar onBack={() => navigate(-1)}>{t('usedCar.pageTitle')}</NavBar> */}
|
||||
|
||||
<div className={styles.createContent}>
|
||||
{/* 产品名称卡片 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={`${styles.formCell} ${styles.formCellVertical}`}>
|
||||
<label className={styles.formLabel}>
|
||||
{t('usedCar.productName')}
|
||||
<span className={styles.required}>*</span>
|
||||
</label>
|
||||
<div className={styles.inputWithAction}>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterProductName')}
|
||||
value={formData.product_name}
|
||||
onChange={(value) => setFormData({ ...formData, product_name: value })}
|
||||
/>
|
||||
</div>
|
||||
<button className={styles.bindBtn} onClick={handleBindByName}>
|
||||
{t('usedCar.search')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 绑定状态提示 */}
|
||||
{boundProduct ? (
|
||||
<div className={styles.bindTip}>
|
||||
<span className={styles.bindTipText}>
|
||||
{t('usedCar.bindBound')}: {boundProduct.brand} · {boundProduct.series}
|
||||
{boundProduct.model && <span> · {boundProduct.model.name}</span>}
|
||||
</span>
|
||||
<span className={styles.bindTipClear} onClick={handleClearBind}>
|
||||
{t('usedCar.bindClear')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.bindTip}>
|
||||
<span>{t('usedCar.bindExample')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.price')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice')}
|
||||
value={formData.price}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, price: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.sourcePlace')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterSourcePlace')}
|
||||
value={formData.source_place}
|
||||
onChange={(value) => setFormData({ ...formData, source_place: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.plateTime')}</label>
|
||||
<div className={styles.pickerTrigger} onClick={() => setPlateTimeVisible(true)}>
|
||||
<span className={formData.plate_time ? styles.pickerValue : styles.pickerPlaceholder}>
|
||||
{formData.plate_time || t('usedCar.selectPlateTime')}
|
||||
</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={plateTimeVisible}
|
||||
onClose={() => setPlateTimeVisible(false)}
|
||||
onConfirm={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
setFormData({ ...formData, plate_time: dateStr })
|
||||
setPlateTimeVisible(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.displacement')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterDisplacement')}
|
||||
value={formData.displacement}
|
||||
onChange={(value) => setFormData({ ...formData, displacement: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.power')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPower')}
|
||||
value={formData.power}
|
||||
onChange={(value) => setFormData({ ...formData, power: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.mileage')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterMileage')}
|
||||
value={formData.mileage}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, mileage: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.bodyColor')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterBodyColor')}
|
||||
value={formData.body_color}
|
||||
onChange={(value) => setFormData({ ...formData, body_color: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品图 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.productImages')}</label>
|
||||
<div className={styles.imageUploadArea}>
|
||||
<ImageUploader
|
||||
value={images.map((url) => ({ url }))}
|
||||
onChange={handleImageChange}
|
||||
onDelete={handleImageDelete}
|
||||
upload={handleImageUpload}
|
||||
maxCount={20}
|
||||
multiple
|
||||
showUpload
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 附件 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.attachments')}</label>
|
||||
<div className={styles.attachmentArea}>
|
||||
<input
|
||||
type="file"
|
||||
id="attachment-upload"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleAttachmentSelect}
|
||||
/>
|
||||
<button
|
||||
className={styles.uploadBtn}
|
||||
onClick={() => document.getElementById('attachment-upload')?.click()}
|
||||
disabled={attachments.length >= 20}
|
||||
>
|
||||
{t('usedCar.uploadAttachments')}
|
||||
</button>
|
||||
<div className={styles.attachmentList}>
|
||||
{attachments.map((file, index) => (
|
||||
<div key={file.id} className={styles.attachmentItem}>
|
||||
<span className={styles.attachmentName}>{file.name}</span>
|
||||
<span
|
||||
className={styles.attachmentDelete}
|
||||
onClick={() => handleAttachmentDelete(index)}
|
||||
>
|
||||
{t('usedCar.removeImage')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 车况介绍 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.conditionDesc')}</label>
|
||||
<div className={styles.admTextAreaWrapper}>
|
||||
<TextArea
|
||||
placeholder={t('usedCar.enterConditionDesc')}
|
||||
value={formData.condition_desc}
|
||||
onChange={(value) => setFormData({ ...formData, condition_desc: value })}
|
||||
rows={4}
|
||||
autoSize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部提交按钮 */}
|
||||
<div className={styles.submitBar}>
|
||||
<Button
|
||||
block
|
||||
className={styles.submitBtn}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={uploadingCount > 0}
|
||||
>
|
||||
{uploadingCount > 0 ? t('usedCar.uploading') : t('usedCar.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 绑定车型弹窗 */}
|
||||
<Modal
|
||||
visible={bindVisible}
|
||||
content={
|
||||
<div className={styles.bindDialogContent}>
|
||||
{/* 弹窗头部 */}
|
||||
<div className={styles.bindDialogHeader}>
|
||||
<span className={styles.bindDialogTitle}>{t('usedCar.bindDialog')}</span>
|
||||
<span className={styles.bindDialogClose} onClick={() => setBindVisible(false)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 品牌/车系信息 */}
|
||||
{bindResult && (
|
||||
<div className={styles.bindBrandInfo}>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.brand')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.brand}</span>
|
||||
<span className={styles.bindBrandInfoSeparator}>·</span>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.series')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.series}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索框 */}
|
||||
{bindResult && bindResult.models.length > 0 && (
|
||||
<div className={styles.bindSearchArea}>
|
||||
<div className={styles.bindSearch}>
|
||||
<Input
|
||||
placeholder={t('usedCar.bindSearchPlaceholder')}
|
||||
value={bindKeyword}
|
||||
onChange={(value) => setBindKeyword(value)}
|
||||
/>
|
||||
{bindKeyword && (
|
||||
<span className={styles.bindSearchClear} onClick={() => setBindKeyword('')}>
|
||||
{t('usedCar.clear')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车型列表 */}
|
||||
<div className={styles.bindList}>
|
||||
{bindLoading ? (
|
||||
<div className={styles.bindLoading}>{t('usedCar.bindSearching')}</div>
|
||||
) : !bindResult ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindSearchFirst')}</div>
|
||||
) : bindResult.models.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoModels')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoResults')}</div>
|
||||
) : (
|
||||
filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`${styles.bindItem} ${bindSelected?.id === model.id ? styles.active : ''}`}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
>
|
||||
<div className={styles.bindItemMain}>
|
||||
<div className={styles.bindItemTitle}>{model.name}</div>
|
||||
{model.msrp > 0 && (
|
||||
<div className={styles.bindItemSub}>
|
||||
{(model.msrp / 10000).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.bindItemRadio}>
|
||||
<div className={styles.bindItemRadioDot} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className={styles.bindDialogFooter}>
|
||||
<button
|
||||
className={styles.bindCancelBtn}
|
||||
onClick={() => setBindVisible(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.bindConfirmBtn}
|
||||
disabled={!bindSelected}
|
||||
onClick={handleConfirmBind}
|
||||
>
|
||||
{t('usedCar.bindConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
closeOnMaskClick
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarCreate
|
||||
@@ -0,0 +1,550 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f6f7fb;
|
||||
}
|
||||
|
||||
.loadingContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 9px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
background: #fff;
|
||||
padding: 9px 9px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridImg {
|
||||
width: calc((100% - 10px) / 3);
|
||||
height: 110px;
|
||||
border-radius: 7px;
|
||||
background: #f3f4f6;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-top: 8px;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
padding: 9px 9px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.heard {
|
||||
padding: 2px 0px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.heard > div:last-child {
|
||||
cursor: pointer;
|
||||
color: #3F435B;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.heard > div:last-child:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kvItem {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.kvItem:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.k {
|
||||
width: 90px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.v {
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.fujian {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid #ebebeb;
|
||||
padding: 4px 5px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.fujian:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fujian:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.fujian p {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fujian > div {
|
||||
padding: 3px 7px;
|
||||
background: #3F435B;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.noneview {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.noneview img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 价格弹窗样式 - 美化版 */
|
||||
.priceDialog {
|
||||
/* width: 100%; */
|
||||
/* max-width: 340px; */
|
||||
/* background: linear-gradient(180deg, #ffffff 0%, #fafbfc 100%); */
|
||||
/* border-radius: 20px; */
|
||||
padding: 24px 20px 20px;
|
||||
/* box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.06); */
|
||||
}
|
||||
|
||||
.pdTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
letter-spacing: -0.3px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.pdRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pdRow:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.pdLabel {
|
||||
width: 70px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex-shrink: 0;
|
||||
padding-top: 11px;
|
||||
}
|
||||
|
||||
.pdPriceLine {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tradeTerm {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
font-size: 15px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.tradeTerm:focus {
|
||||
outline: none;
|
||||
border-color: #3F435B;
|
||||
box-shadow: 0 0 0 3px rgba(63, 67, 91, 0.1);
|
||||
}
|
||||
|
||||
.pdInputSuffixWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pdInputPrice {
|
||||
width: 100%;
|
||||
height: 44px !important;
|
||||
border: 2px solid #e8e8e8 !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 0 14px !important;
|
||||
font-size: 15px !important;
|
||||
transition: all 0.2s;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.pdInputPrice:focus {
|
||||
border-color: #3F435B !important;
|
||||
box-shadow: 0 0 0 3px rgba(63, 67, 91, 0.1) !important;
|
||||
}
|
||||
|
||||
.pdCurrencySuffix {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 80px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.pdCurrencySuffix select {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
padding: 0 4px 0 8px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 4px center;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.pdRadioGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pdRadioGroup :global(.adm-radio) {
|
||||
--checked-color: #3F435B;
|
||||
--size: 18px;
|
||||
}
|
||||
|
||||
.pdRadioGroup :global(.adm-radio .adm-radio-content) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pdRadioText {
|
||||
margin-left: 6px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pdActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.pdCancel,
|
||||
.pdConfirm {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pdCancel {
|
||||
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pdCancel:active {
|
||||
background: linear-gradient(135deg, #e8e8e8 0%, #d4d4d4 100%);
|
||||
}
|
||||
|
||||
.pdConfirm {
|
||||
background: linear-gradient(345deg, #3F435B 0%, #4a4f6a 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(63, 67, 91, 0.3);
|
||||
}
|
||||
|
||||
.pdConfirm:active {
|
||||
box-shadow: 0 2px 6px rgba(63, 67, 91, 0.3);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* 新详情页样式 */
|
||||
.detailPage {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.detailContent {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.codeBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionValue {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.infoRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.imageItem {
|
||||
position: relative;
|
||||
padding-bottom: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.imageThumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachmentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachmentItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: #f8f8f8;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachmentName {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachmentDownload {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timeInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 价格弹窗样式 */
|
||||
.priceModalContent {
|
||||
padding: 16px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.formItem {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.formItem:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.selectWrapper select {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Button,
|
||||
Modal,
|
||||
Input,
|
||||
Toast,
|
||||
ImageViewer,
|
||||
Empty,
|
||||
Grid
|
||||
} from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import styles from './detail.module.css'
|
||||
|
||||
// 贸易条款选项
|
||||
const TRADE_TERMS = [
|
||||
{ value: 'FOB', text: 'FOB' },
|
||||
{ value: 'CIF', text: 'CIF' },
|
||||
{ value: 'EXW', text: 'EXW' },
|
||||
{ value: 'DDP', text: 'DDP' },
|
||||
{ value: 'CIP', text: 'CIP' },
|
||||
{ value: 'FCA', text: 'FCA' },
|
||||
{ value: 'CFR', text: 'CFR' },
|
||||
]
|
||||
|
||||
// 币种选项
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', text: '美元' },
|
||||
{ value: 'CNY', text: '人民币' },
|
||||
{ value: 'RUB', text: '卢布' },
|
||||
{ value: 'AED', text: '迪拉姆' },
|
||||
{ value: 'EUR', text: '欧元' }
|
||||
]
|
||||
|
||||
// 语言选项
|
||||
const LANGUAGES = [
|
||||
{ value: 'en', text: '英语' },
|
||||
{ value: 'ru', text: '俄语' },
|
||||
{ value: 'es', text: '西班牙语' },
|
||||
{ value: 'ar', text: '阿拉伯语' }
|
||||
]
|
||||
|
||||
interface UsedCarDetail {
|
||||
id: number
|
||||
code: string
|
||||
product_name: string
|
||||
vin?: string
|
||||
price?: number
|
||||
source_place?: string
|
||||
plate_place?: string
|
||||
plate_time?: string
|
||||
displacement?: string
|
||||
mileage?: number
|
||||
power?: string
|
||||
body_color?: string
|
||||
condition_desc?: string
|
||||
images?: string
|
||||
images_pull?: Array<{ url: string; name?: string }>
|
||||
images_array?: string[]
|
||||
attachment?: string
|
||||
attachment_pull?: Array<{ url: string; name: string; src_pull?: string }>
|
||||
attachment_array?: Array<{ url: string; name: string }>
|
||||
pdf_src?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// H5 下载:用 a 标签触发
|
||||
const downloadByAnchor = (url: string, filename?: string) => {
|
||||
try {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
if (filename) {
|
||||
a.download = filename
|
||||
}
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch {
|
||||
// 兜底:直接打开
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const UsedCarDetail: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
|
||||
const [detail, setDetail] = useState<UsedCarDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 图片预览
|
||||
const [imageViewVisible, setImageViewVisible] = useState(false)
|
||||
const [imageViewIndex, setImageViewIndex] = useState(0)
|
||||
|
||||
// 价格弹窗
|
||||
const [priceModalVisible, setPriceModalVisible] = useState(false)
|
||||
const [priceData, setPriceData] = useState({
|
||||
price: '',
|
||||
currency: 'USD',
|
||||
lang: 'en',
|
||||
trade_term: 'FOB'
|
||||
})
|
||||
const [generatingPdf, setGeneratingPdf] = useState(false)
|
||||
|
||||
// 处理图片 URL(如果是相对路径或 localhost 则转换为完整 URL)
|
||||
const processImageUrl = (url: string): string => {
|
||||
if (!url) return ''
|
||||
// 如果已经是完整 URL 且不是 localhost,直接返回
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
// 如果是 localhost,替换为实际后端地址
|
||||
if (url.includes('http://localhost/')) {
|
||||
return url.replace('http://localhost/', 'http://localhost:8787/')
|
||||
}
|
||||
if (url.includes('http://localhost:')) {
|
||||
return url
|
||||
}
|
||||
return url
|
||||
}
|
||||
// 相对路径,添加后端地址
|
||||
return `http://localhost:8787${url.startsWith('/') ? '' : '/'}${url}`
|
||||
}
|
||||
|
||||
// 获取图片列表
|
||||
const getImages = (): string[] => {
|
||||
if (!detail) return []
|
||||
// 优先使用 images_array(后端返回的主要字段)
|
||||
if (detail.images_array && detail.images_array.length > 0) {
|
||||
return detail.images_array.map(processImageUrl)
|
||||
}
|
||||
if (detail.images_pull && detail.images_pull.length > 0) {
|
||||
return detail.images_pull.map((img: { url?: string } | string) => {
|
||||
const url = typeof img === 'string' ? img : img.url
|
||||
return processImageUrl(url || '')
|
||||
})
|
||||
}
|
||||
if (detail.images) {
|
||||
try {
|
||||
const parsed = JSON.parse(detail.images)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(processImageUrl)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 获取附件列表
|
||||
const getAttachments = (): Array<{ url: string; name: string }> => {
|
||||
if (!detail) return []
|
||||
// 优先使用 attachment_array(后端返回的主要字段)
|
||||
if (detail.attachment_array && detail.attachment_array.length > 0) {
|
||||
return detail.attachment_array.map(att => ({
|
||||
url: processImageUrl(att.url),
|
||||
name: att.name
|
||||
}))
|
||||
}
|
||||
if (detail.attachment_pull && detail.attachment_pull.length > 0) {
|
||||
return detail.attachment_pull.map(att => ({
|
||||
url: processImageUrl(att.url || att.src_pull || ''),
|
||||
name: att.name
|
||||
}))
|
||||
}
|
||||
if (detail.attachment) {
|
||||
try {
|
||||
const parsed = JSON.parse(detail.attachment) as Array<{ url?: string; name?: string }>
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((att) => ({
|
||||
url: processImageUrl(att.url || ''),
|
||||
name: att.name || ''
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 加载详情
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
if (!id) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 响应拦截器已经返回 data.data,直接使用即可
|
||||
const detailData = await usedCarApi.getDetail(Number(id))
|
||||
setDetail(detailData as UsedCarDetail)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.loadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
navigate(-1)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDetail()
|
||||
}, [id])
|
||||
|
||||
// 下载PDF - 如果存在 URL 直接下载,否则请求价格
|
||||
const handleDownloadPdf = () => {
|
||||
if (!detail) return
|
||||
|
||||
// 如果有 PDF URL,直接下载
|
||||
if (detail.pdf_src && detail.pdf_src.trim() !== '') {
|
||||
downloadByAnchor(detail.pdf_src, `${detail.product_name}-${detail.code}.pdf`)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有 PDF URL,弹出价格输入框
|
||||
setPriceModalVisible(true)
|
||||
}
|
||||
|
||||
// 生成PDF
|
||||
const handleGeneratePdf = async () => {
|
||||
if (!detail || !priceData.price) {
|
||||
return Toast.show({ content: t('usedCar.enterPrice'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setGeneratingPdf(true)
|
||||
try {
|
||||
const res = await usedCarApi.generatePdf({
|
||||
id: detail.id,
|
||||
price: Number(priceData.price),
|
||||
currency: priceData.currency,
|
||||
lang: priceData.lang,
|
||||
trade_term: priceData.trade_term
|
||||
}) as { pdf_url?: string; file_name?: string }
|
||||
|
||||
if (res.pdf_url) {
|
||||
downloadByAnchor(res.pdf_url, res.file_name || `${detail.product_name}-${detail.code}.pdf`)
|
||||
Toast.show({ content: t('usedCar.pdfDownloadSuccess') || 'PDF 下载成功', icon: 'success' })
|
||||
setPriceModalVisible(false)
|
||||
// 刷新详情
|
||||
const refreshedDetail = await usedCarApi.getDetail(Number(id))
|
||||
setDetail(refreshedDetail as UsedCarDetail)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : (t('usedCar.pdfDownloadFailed') || 'PDF 生成失败')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setGeneratingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 分享
|
||||
const handleShare = async () => {
|
||||
if (!detail) return
|
||||
const title = detail.product_name || '车辆详情'
|
||||
const link = `${window.location.origin}/#/used-car/detail/${detail.id}`
|
||||
const images = getImages()
|
||||
|
||||
// 检测是否在飞书环境
|
||||
const isFeishuEnv = /feishu|lark/i.test(navigator.userAgent)
|
||||
|
||||
if (isFeishuEnv) {
|
||||
try {
|
||||
// 等待飞书 SDK 准备就绪
|
||||
const waitForFeishuSDK = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.tt && (window as any).h5sdk) {
|
||||
(window as any).h5sdk.ready(() => resolve())
|
||||
return
|
||||
}
|
||||
let attempts = 0
|
||||
const maxAttempts = 50
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++
|
||||
if (window.tt && (window as any).h5sdk) {
|
||||
clearInterval(checkInterval)
|
||||
;(window as any).h5sdk.ready(() => resolve())
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(checkInterval)
|
||||
reject(new Error('TT_OBJECT_TIMEOUT'))
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
await waitForFeishuSDK()
|
||||
|
||||
// 调用飞书分享
|
||||
;(window as any).tt.shareWebContent({
|
||||
title,
|
||||
content: title,
|
||||
url: link,
|
||||
imageUrl: images[0] || '',
|
||||
success: () => {
|
||||
Toast.show({ content: '已唤起飞书分享', icon: 'success' })
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('飞书分享失败:', err)
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('飞书 SDK 加载失败:', error)
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, text: title, url: link }).catch(() => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
})
|
||||
})
|
||||
} else {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 非飞书环境,使用 Web Share API
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, text: title, url: link }).catch(() => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 最后的兜底:复制链接
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
Toast.show({ content: '链接已复制', icon: 'success' })
|
||||
}).catch(() => {
|
||||
Toast.show({ content: '复制失败', icon: 'fail' })
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = () => {
|
||||
navigate(`/used-car/edit/${id}`)
|
||||
}
|
||||
|
||||
// 图片查看
|
||||
const images = getImages()
|
||||
const attachments = getAttachments()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.detailPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.detail')}</NavBar>
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#999' }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className={styles.detailPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.detail')}</NavBar>
|
||||
<Empty description={t('usedCar.notFound')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.detailPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.detail')}</NavBar>
|
||||
|
||||
<div className={styles.detailContent}>
|
||||
{/* 编码 */}
|
||||
<div className={styles.codeBar}>
|
||||
<span className={styles.code}>{t('usedCar.code')}: {detail.code || '~'}</span>
|
||||
{/* <Button size="mini" color="primary" onClick={handleEdit}>
|
||||
{t('usedCar.edit')}
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
{/* 产品名称 */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('usedCar.productName')}</div>
|
||||
<div className={styles.sectionValue}>{detail.product_name || '-'}</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息卡片 */}
|
||||
<div className={styles.infoCard}>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.price')}</span>
|
||||
<span className={styles.infoValue}>{detail.price ? `¥${detail.price}` : '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.sourcePlace')}</span>
|
||||
<span className={styles.infoValue}>{detail.source_place || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.platePlace')}</span>
|
||||
<span className={styles.infoValue}>{detail.plate_place || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.plateTime')}</span>
|
||||
<span className={styles.infoValue}>{detail.plate_time ? String(detail.plate_time).slice(0, 7) : '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.displacement')}</span>
|
||||
<span className={styles.infoValue}>{detail.displacement || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.power')}</span>
|
||||
<span className={styles.infoValue}>{detail.power || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.mileage')}</span>
|
||||
<span className={styles.infoValue}>{detail.mileage ? `${detail.mileage} Km` : '-'}</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>{t('usedCar.bodyColor')}</span>
|
||||
<span className={styles.infoValue}>{detail.body_color || '-'}</span>
|
||||
</div>
|
||||
{detail.vin && (
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>VIN</span>
|
||||
<span className={styles.infoValue}>{detail.vin}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片 */}
|
||||
{images.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('usedCar.productImages')}</div>
|
||||
<Grid columns={3} gap={8}>
|
||||
{images.map((url, index) => (
|
||||
<Grid.Item key={index}>
|
||||
<div
|
||||
className={styles.imageItem}
|
||||
onClick={() => {
|
||||
setImageViewIndex(index)
|
||||
setImageViewVisible(true)
|
||||
}}
|
||||
>
|
||||
<img src={url} alt="" className={styles.imageThumb} />
|
||||
</div>
|
||||
</Grid.Item>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车况介绍 */}
|
||||
{detail.condition_desc && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('usedCar.conditionDesc')}</div>
|
||||
<div className={styles.sectionValue}>{detail.condition_desc}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 附件 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('usedCar.attachments')}</div>
|
||||
<div className={styles.attachmentList}>
|
||||
{attachments.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.attachmentItem}
|
||||
onClick={() => downloadByAnchor(file.url, file.name)}
|
||||
>
|
||||
<span className={styles.attachmentName}>{file.name}</span>
|
||||
<span className={styles.attachmentDownload}>📥</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className={styles.timeInfo}>
|
||||
<span>{t('usedCar.createdAt')}: {detail.created_at}</span>
|
||||
{detail.updated_at !== detail.created_at && (
|
||||
<span>{t('usedCar.updatedAt')}: {detail.updated_at}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className={styles.bottomBar}>
|
||||
<Button className={styles.actionBtn} onClick={handleDownloadPdf}>
|
||||
📥 {t('usedCar.downloadPdf')}
|
||||
</Button>
|
||||
<Button className={styles.actionBtn} onClick={handleShare}>
|
||||
🔗 {t('usedCar.share')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageViewer
|
||||
image={images[imageViewIndex]}
|
||||
visible={imageViewVisible}
|
||||
onClose={() => setImageViewVisible(false)}
|
||||
/>
|
||||
|
||||
{/* 价格输入弹窗 */}
|
||||
<Modal
|
||||
visible={priceModalVisible}
|
||||
title={t('usedCar.generatePdf')}
|
||||
onClose={() => setPriceModalVisible(false)}
|
||||
content={
|
||||
<div className={styles.priceModalContent}>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formLabel}>{t('usedCar.price')}</label>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice')}
|
||||
type="number"
|
||||
value={priceData.price}
|
||||
onChange={value => setPriceData({ ...priceData, price: value })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formLabel}>{t('usedCar.currency')}</label>
|
||||
<div className={styles.selectWrapper}>
|
||||
<select
|
||||
value={priceData.currency}
|
||||
onChange={e => setPriceData({ ...priceData, currency: e.target.value })}
|
||||
>
|
||||
{CURRENCIES.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.text}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formLabel}>{t('usedCar.language')}</label>
|
||||
<div className={styles.selectWrapper}>
|
||||
<select
|
||||
value={priceData.lang}
|
||||
onChange={e => setPriceData({ ...priceData, lang: e.target.value })}
|
||||
>
|
||||
{LANGUAGES.map(l => (
|
||||
<option key={l.value} value={l.value}>{l.text}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formLabel}>{t('usedCar.tradeTerm')}</label>
|
||||
<div className={styles.selectWrapper}>
|
||||
<select
|
||||
value={priceData.trade_term}
|
||||
onChange={e => setPriceData({ ...priceData, trade_term: e.target.value })}
|
||||
>
|
||||
{TRADE_TERMS.map(term => (
|
||||
<option key={term.value} value={term.value}>{term.text}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
loading={generatingPdf}
|
||||
onClick={async () => {
|
||||
await handleGeneratePdf()
|
||||
}}
|
||||
>
|
||||
{t('usedCar.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarDetail
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Button,
|
||||
Modal,
|
||||
Input,
|
||||
Toast,
|
||||
ImageViewer,
|
||||
Empty
|
||||
} from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import styles from './detail.module.css'
|
||||
|
||||
// 贸易条款选项
|
||||
const TRADE_TERMS = [
|
||||
{ value: 'FOB', text: 'FOB' },
|
||||
{ value: 'CIF', text: 'CIF' },
|
||||
{ value: 'EXW', text: 'EXW' },
|
||||
{ value: 'DDP', text: 'DDP' },
|
||||
{ value: 'CIP', text: 'CIP' },
|
||||
{ value: 'FCA', text: 'FCA' },
|
||||
{ value: 'CFR', text: 'CFR' },
|
||||
]
|
||||
|
||||
// 币种选项
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', text: '美元' },
|
||||
{ value: 'CNY', text: '人民币' },
|
||||
{ value: 'RUB', text: '卢布' },
|
||||
{ value: 'AED', text: '迪拉姆' },
|
||||
{ value: 'EUR', text: '欧元' }
|
||||
]
|
||||
|
||||
// 语言选项
|
||||
const LANGUAGES = [
|
||||
{ value: 'en', text: '英语' },
|
||||
{ value: 'ru', text: '俄语' },
|
||||
{ value: 'es', text: '西班牙语' },
|
||||
{ value: 'ar', text: '阿拉伯语' }
|
||||
]
|
||||
|
||||
interface UsedCarDetail {
|
||||
id: number
|
||||
code: string
|
||||
product_name: string
|
||||
vin?: string
|
||||
price?: number
|
||||
source_place?: string
|
||||
plate_place?: string
|
||||
plate_time?: string
|
||||
displacement?: string
|
||||
mileage?: number
|
||||
power?: string
|
||||
body_color?: string
|
||||
condition_desc?: string
|
||||
images?: string
|
||||
images_pull?: Array<{ url: string; name?: string }>
|
||||
images_array?: string[]
|
||||
attachment?: string
|
||||
attachment_pull?: Array<{ url: string; name: string; src_pull?: string }>
|
||||
attachment_array?: Array<{ url: string; name: string }>
|
||||
pdf_src?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Input,
|
||||
Button,
|
||||
DatePicker,
|
||||
Toast,
|
||||
TextArea,
|
||||
ImageUploader,
|
||||
Modal
|
||||
} from 'antd-mobile'
|
||||
import { uploadApi, usedCarApi } from '@/services/api'
|
||||
import styles from './create.module.css'
|
||||
|
||||
// 车型接口
|
||||
interface CarModel {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
msrp: number
|
||||
}
|
||||
|
||||
// 关键词搜索结果接口
|
||||
interface KeywordSearchResult {
|
||||
brand: string
|
||||
series: string
|
||||
series_id: string
|
||||
models: CarModel[]
|
||||
}
|
||||
|
||||
// 绑定状态接口
|
||||
interface BoundProduct {
|
||||
brand?: string
|
||||
series?: string
|
||||
series_id?: string
|
||||
model?: CarModel
|
||||
}
|
||||
|
||||
// 二手车详情接口
|
||||
interface UsedCarDetail {
|
||||
id: number
|
||||
code: string
|
||||
product_name: string
|
||||
vin?: string
|
||||
price?: number
|
||||
source_place?: string
|
||||
plate_place?: string
|
||||
plate_time?: string
|
||||
displacement?: string
|
||||
mileage?: number
|
||||
power?: string
|
||||
body_color?: string
|
||||
condition_desc?: string
|
||||
images?: string
|
||||
images_pull?: Array<{ url: string }>
|
||||
images_array?: string[]
|
||||
attachment?: string
|
||||
attachment_pull?: Array<{ url: string; name: string }>
|
||||
attachment_array?: Array<{ url: string; name: string }>
|
||||
crawl_brand?: string
|
||||
crawl_series?: string
|
||||
crawl_series_id?: string
|
||||
crawl_model_name?: string
|
||||
crawl_model_url?: string
|
||||
crawl_msrp?: string
|
||||
}
|
||||
|
||||
// 图片文件项接口
|
||||
interface ImageFileItem {
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
const UsedCarEdit: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({
|
||||
product_name: '',
|
||||
vin: '',
|
||||
price: '',
|
||||
source_place: '',
|
||||
plate_place: '',
|
||||
plate_time: '',
|
||||
displacement: '',
|
||||
power: '',
|
||||
body_color: '',
|
||||
condition_desc: '',
|
||||
mileage: '',
|
||||
// 绑定车型数据字段
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
|
||||
// 图片和附件
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [attachments, setAttachments] = useState<Array<{ id: number; name: string; url: string; size: number }>>([])
|
||||
const [uploadingCount, setUploadingCount] = useState(0)
|
||||
|
||||
// 绑定弹窗状态
|
||||
const [bindVisible, setBindVisible] = useState(false)
|
||||
const [bindLoading, setBindLoading] = useState(false)
|
||||
const [bindResult, setBindResult] = useState<KeywordSearchResult | null>(null)
|
||||
const [bindSelected, setBindSelected] = useState<CarModel | null>(null)
|
||||
const [boundProduct, setBoundProduct] = useState<BoundProduct | null>(null)
|
||||
const [bindKeyword, setBindKeyword] = useState('')
|
||||
|
||||
// 提交中
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 上牌时间选择
|
||||
const [plateTimeVisible, setPlateTimeVisible] = useState(false)
|
||||
|
||||
// 加载详情
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
if (!id) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 响应拦截器已经返回 data.data,直接使用即可
|
||||
const detail = await usedCarApi.getDetail(Number(id)) as UsedCarDetail
|
||||
|
||||
// 解析图片 - 优先使用 images_array
|
||||
let imageUrls: string[] = []
|
||||
if (detail.images_array && detail.images_array.length > 0) {
|
||||
imageUrls = detail.images_array
|
||||
} else if (detail.images_pull && detail.images_pull.length > 0) {
|
||||
imageUrls = detail.images_pull.map(img => img.url)
|
||||
} else if (detail.images) {
|
||||
try {
|
||||
imageUrls = JSON.parse(detail.images)
|
||||
if (!Array.isArray(imageUrls)) imageUrls = []
|
||||
} catch {
|
||||
imageUrls = []
|
||||
}
|
||||
}
|
||||
|
||||
// 解析附件 - 优先使用 attachment_array
|
||||
let attachmentList: Array<{ id: number; name: string; url: string; size: number }> = []
|
||||
if (detail.attachment_array && detail.attachment_array.length > 0) {
|
||||
attachmentList = detail.attachment_array.map((item, index) => ({
|
||||
id: index,
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
size: 0
|
||||
}))
|
||||
} else if (detail.attachment_pull && detail.attachment_pull.length > 0) {
|
||||
attachmentList = detail.attachment_pull.map((item, index) => ({
|
||||
id: index,
|
||||
name: item.name || '',
|
||||
url: item.url || '',
|
||||
size: 0
|
||||
}))
|
||||
} else if (detail.attachment) {
|
||||
try {
|
||||
const parsed = JSON.parse(detail.attachment)
|
||||
if (Array.isArray(parsed)) {
|
||||
attachmentList = parsed.map((item, index) => ({
|
||||
id: index,
|
||||
name: item.name || '',
|
||||
url: item.url || '',
|
||||
size: item.size || 0
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
attachmentList = []
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({
|
||||
product_name: detail.product_name || '',
|
||||
vin: detail.vin || '',
|
||||
price: detail.price ? String(detail.price) : '',
|
||||
source_place: detail.source_place || '',
|
||||
plate_place: detail.plate_place || '',
|
||||
plate_time: detail.plate_time || '',
|
||||
displacement: detail.displacement || '',
|
||||
power: detail.power || '',
|
||||
body_color: detail.body_color || '',
|
||||
condition_desc: detail.condition_desc || '',
|
||||
mileage: detail.mileage ? String(detail.mileage) : '',
|
||||
crawl_brand: detail.crawl_brand || '',
|
||||
crawl_series: detail.crawl_series || '',
|
||||
crawl_series_id: detail.crawl_series_id || '',
|
||||
crawl_model_name: detail.crawl_model_name || '',
|
||||
crawl_model_url: detail.crawl_model_url || '',
|
||||
crawl_msrp: detail.crawl_msrp || ''
|
||||
})
|
||||
|
||||
setImages(imageUrls)
|
||||
setAttachments(attachmentList)
|
||||
|
||||
// 设置已绑定产品状态
|
||||
if (detail.crawl_brand || detail.crawl_series) {
|
||||
setBoundProduct({
|
||||
brand: detail.crawl_brand,
|
||||
series: detail.crawl_series,
|
||||
series_id: detail.crawl_series_id || '',
|
||||
model: detail.crawl_model_name ? {
|
||||
id: 0,
|
||||
name: detail.crawl_model_name,
|
||||
url: detail.crawl_model_url || '',
|
||||
msrp: detail.crawl_msrp ? Number(detail.crawl_msrp) : 0
|
||||
} : undefined
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.loadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
navigate(-1)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDetail()
|
||||
}, [id])
|
||||
|
||||
// 产品名称绑定(关键词搜索)
|
||||
const handleBindByName = async () => {
|
||||
const name = formData.product_name.trim()
|
||||
if (!name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setBindVisible(true)
|
||||
setBindLoading(true)
|
||||
setBindResult(null)
|
||||
setBindSelected(null)
|
||||
setBindKeyword('')
|
||||
|
||||
try {
|
||||
const res = await usedCarApi.crawlKeyword(name)
|
||||
setBindResult(res)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.bindFetchFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setBindLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认绑定
|
||||
const handleConfirmBind = () => {
|
||||
if (!bindSelected || !bindResult) return
|
||||
|
||||
setBoundProduct({
|
||||
brand: bindResult.brand,
|
||||
series: bindResult.series,
|
||||
series_id: bindResult.series_id,
|
||||
model: bindSelected
|
||||
})
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: bindResult.brand,
|
||||
crawl_series: bindResult.series,
|
||||
crawl_series_id: bindResult.series_id,
|
||||
crawl_model_name: bindSelected.name,
|
||||
crawl_model_url: bindSelected.url,
|
||||
crawl_msrp: bindSelected.msrp ? String(bindSelected.msrp) : ''
|
||||
})
|
||||
|
||||
setBindVisible(false)
|
||||
Toast.show({ content: t('usedCar.bindSuccess'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 清除绑定
|
||||
const handleClearBind = () => {
|
||||
setBoundProduct(null)
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
Toast.show({ content: t('usedCar.bindCleared'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 过滤车型列表
|
||||
const filteredModels = bindResult?.models.filter((model) => {
|
||||
const kw = bindKeyword.toLowerCase().trim()
|
||||
if (!kw) return true
|
||||
const name = String(model.name || '').toLowerCase()
|
||||
return name.includes(kw)
|
||||
}) || []
|
||||
|
||||
// 选择车型
|
||||
const handleSelectModel = (model: CarModel) => {
|
||||
setBindSelected(model)
|
||||
}
|
||||
|
||||
// 图片上传处理(在 ImageUploader 的 upload prop 中调用)
|
||||
const handleImageUpload = async (file: File): Promise<{ url: string }> => {
|
||||
setUploadingCount(prev => prev + 1)
|
||||
try {
|
||||
const result: any = await uploadApi.uploadUsedCarImage(file)
|
||||
return { url: result.full_url || result.url }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
} finally {
|
||||
setUploadingCount(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片变化处理
|
||||
const handleImageChange = (newFiles: ImageFileItem[]) => {
|
||||
// 更新图片列表
|
||||
setImages(newFiles.map(f => f.url))
|
||||
}
|
||||
|
||||
// 图片删除处理
|
||||
const handleImageDelete = (deletedFile: ImageFileItem) => {
|
||||
const newImages = images.filter(url => url !== deletedFile.url)
|
||||
setImages(newImages)
|
||||
}
|
||||
|
||||
// 附件上传处理
|
||||
const handleAttachmentUpload = async (file: File): Promise<{ id: number; name: string; url: string; size: number }> => {
|
||||
try {
|
||||
const result: any = await uploadApi.uploadFile(file, 'other', 'usedcar')
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.file_name || file.name,
|
||||
url: result.url,
|
||||
size: result.file_size || file.size
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 附件选择处理
|
||||
const handleAttachmentSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const newAttachments = [...attachments]
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (newAttachments.length + i >= 20) {
|
||||
Toast.show({ content: t('usedCar.maxAttachments'), icon: 'fail' })
|
||||
break
|
||||
}
|
||||
try {
|
||||
const result = await handleAttachmentUpload(file)
|
||||
newAttachments.push(result)
|
||||
} catch {
|
||||
// 上传失败已在 handleAttachmentUpload 中处理
|
||||
}
|
||||
}
|
||||
setAttachments(newAttachments)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 附件删除处理
|
||||
const handleAttachmentDelete = (index: number) => {
|
||||
const newAttachments = attachments.filter((_, i) => i !== index)
|
||||
setAttachments(newAttachments)
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.product_name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
// 检查是否有正在上传的图片
|
||||
if (uploadingCount > 0) {
|
||||
return Toast.show({ content: t('usedCar.uploading'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
id: Number(id),
|
||||
...formData,
|
||||
price: formData.price ? Number(formData.price) : undefined,
|
||||
mileage: formData.mileage ? Number(formData.mileage) : undefined,
|
||||
crawl_msrp: formData.crawl_msrp ? Number(formData.crawl_msrp) : undefined,
|
||||
// 图片数组转为逗号分隔字符串
|
||||
images: images.join(','),
|
||||
// 附件数组
|
||||
attachment: attachments,
|
||||
}
|
||||
|
||||
// 响应拦截器已经返回 data.data,直接使用即可
|
||||
await usedCarApi.update(payload)
|
||||
Toast.show({ content: t('usedCar.updateSuccess'), icon: 'success' })
|
||||
setTimeout(() => {
|
||||
navigate(-1)
|
||||
}, 1000)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('common.error')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.createPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.edit')}</NavBar>
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#999' }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.createPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.edit')}</NavBar>
|
||||
|
||||
<div className={styles.createContent}>
|
||||
{/* 产品名称卡片 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={`${styles.formCell} ${styles.formCellVertical}`}>
|
||||
<label className={styles.formLabel}>
|
||||
{t('usedCar.productName')}
|
||||
<span className={styles.required}>*</span>
|
||||
</label>
|
||||
<div className={styles.inputWithAction}>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterProductName')}
|
||||
value={formData.product_name}
|
||||
onChange={(value) => setFormData({ ...formData, product_name: value })}
|
||||
/>
|
||||
</div>
|
||||
<button className={styles.bindBtn} onClick={handleBindByName}>
|
||||
{t('usedCar.search')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 绑定状态提示 */}
|
||||
{boundProduct ? (
|
||||
<div className={styles.bindTip}>
|
||||
<span className={styles.bindTipText}>
|
||||
{t('usedCar.bindBound')}: {boundProduct.brand} · {boundProduct.series}
|
||||
{boundProduct.model && <span> · {boundProduct.model.name}</span>}
|
||||
</span>
|
||||
<span className={styles.bindTipClear} onClick={handleClearBind}>
|
||||
{t('usedCar.bindClear')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.bindTip}>
|
||||
<span>{t('usedCar.bindExample')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.price')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice')}
|
||||
value={formData.price}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, price: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.sourcePlace')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterSourcePlace')}
|
||||
value={formData.source_place}
|
||||
onChange={(value) => setFormData({ ...formData, source_place: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.plateTime')}</label>
|
||||
<div className={styles.pickerTrigger} onClick={() => setPlateTimeVisible(true)}>
|
||||
<span className={formData.plate_time ? styles.pickerValue : styles.pickerPlaceholder}>
|
||||
{formData.plate_time || t('usedCar.selectPlateTime')}
|
||||
</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={plateTimeVisible}
|
||||
onClose={() => setPlateTimeVisible(false)}
|
||||
value={formData.plate_time ? new Date(formData.plate_time) : undefined}
|
||||
onConfirm={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
setFormData({ ...formData, plate_time: dateStr })
|
||||
setPlateTimeVisible(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.displacement')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterDisplacement')}
|
||||
value={formData.displacement}
|
||||
onChange={(value) => setFormData({ ...formData, displacement: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.power')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPower')}
|
||||
value={formData.power}
|
||||
onChange={(value) => setFormData({ ...formData, power: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.mileage')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterMileage')}
|
||||
value={formData.mileage}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, mileage: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.bodyColor')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterBodyColor')}
|
||||
value={formData.body_color}
|
||||
onChange={(value) => setFormData({ ...formData, body_color: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品图 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.productImages')}</label>
|
||||
<div className={styles.imageUploadArea}>
|
||||
<ImageUploader
|
||||
value={images.map((url) => ({ url }))}
|
||||
onChange={handleImageChange}
|
||||
onDelete={handleImageDelete}
|
||||
upload={handleImageUpload}
|
||||
maxCount={20}
|
||||
multiple
|
||||
showUpload
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 车况介绍 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.conditionDesc')}</label>
|
||||
<div className={styles.admTextAreaWrapper}>
|
||||
<TextArea
|
||||
placeholder={t('usedCar.enterConditionDesc')}
|
||||
value={formData.condition_desc}
|
||||
onChange={(value) => setFormData({ ...formData, condition_desc: value })}
|
||||
rows={4}
|
||||
autoSize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 附件 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.attachments')}</label>
|
||||
<div className={styles.attachmentArea}>
|
||||
<input
|
||||
type="file"
|
||||
id="attachment-upload-edit"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleAttachmentSelect}
|
||||
/>
|
||||
<button
|
||||
className={styles.uploadBtn}
|
||||
onClick={() => document.getElementById('attachment-upload-edit')?.click()}
|
||||
disabled={attachments.length >= 20}
|
||||
>
|
||||
{t('usedCar.uploadAttachments')}
|
||||
</button>
|
||||
<div className={styles.attachmentList}>
|
||||
{attachments.map((file, index) => (
|
||||
<div key={file.id} className={styles.attachmentItem}>
|
||||
<span className={styles.attachmentName}>{file.name}</span>
|
||||
<span
|
||||
className={styles.attachmentDelete}
|
||||
onClick={() => handleAttachmentDelete(index)}
|
||||
>
|
||||
{t('usedCar.removeImage')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部提交按钮 */}
|
||||
<div className={styles.submitBar}>
|
||||
<Button
|
||||
block
|
||||
className={styles.submitBtn}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={uploadingCount > 0}
|
||||
>
|
||||
{uploadingCount > 0 ? t('usedCar.uploading') : t('usedCar.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 绑定车型弹窗 */}
|
||||
<Modal
|
||||
visible={bindVisible}
|
||||
content={
|
||||
<div className={styles.bindDialogContent}>
|
||||
{/* 弹窗头部 */}
|
||||
<div className={styles.bindDialogHeader}>
|
||||
<span className={styles.bindDialogTitle}>{t('usedCar.bindDialog')}</span>
|
||||
<span className={styles.bindDialogClose} onClick={() => setBindVisible(false)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 品牌/车系信息 */}
|
||||
{bindResult && (
|
||||
<div className={styles.bindBrandInfo}>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.brand')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.brand}</span>
|
||||
<span className={styles.bindBrandInfoSeparator}>·</span>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.series')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.series}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索框 */}
|
||||
{bindResult && bindResult.models.length > 0 && (
|
||||
<div className={styles.bindSearchArea}>
|
||||
<div className={styles.bindSearch}>
|
||||
<Input
|
||||
placeholder={t('usedCar.bindSearchPlaceholder')}
|
||||
value={bindKeyword}
|
||||
onChange={(value) => setBindKeyword(value)}
|
||||
/>
|
||||
{bindKeyword && (
|
||||
<span className={styles.bindSearchClear} onClick={() => setBindKeyword('')}>
|
||||
{t('usedCar.clear')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车型列表 */}
|
||||
<div className={styles.bindList}>
|
||||
{bindLoading ? (
|
||||
<div className={styles.bindLoading}>{t('usedCar.bindSearching')}</div>
|
||||
) : !bindResult ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindSearchFirst')}</div>
|
||||
) : bindResult.models.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoModels')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoResults')}</div>
|
||||
) : (
|
||||
filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`${styles.bindItem} ${bindSelected?.id === model.id ? styles.active : ''}`}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
>
|
||||
<div className={styles.bindItemMain}>
|
||||
<div className={styles.bindItemTitle}>{model.name}</div>
|
||||
{model.msrp > 0 && (
|
||||
<div className={styles.bindItemSub}>
|
||||
{(model.msrp / 10000).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.bindItemRadio}>
|
||||
<div className={styles.bindItemRadioDot} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className={styles.bindDialogFooter}>
|
||||
<button
|
||||
className={styles.bindCancelBtn}
|
||||
onClick={() => setBindVisible(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.bindConfirmBtn}
|
||||
disabled={!bindSelected}
|
||||
onClick={handleConfirmBind}
|
||||
>
|
||||
{t('usedCar.bindConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
closeOnMaskClick
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarEdit
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
NavBar,
|
||||
Input,
|
||||
Button,
|
||||
DatePicker,
|
||||
Toast,
|
||||
TextArea,
|
||||
ImageUploader,
|
||||
Modal
|
||||
} from 'antd-mobile'
|
||||
import { uploadApi, usedCarApi } from '@/services/api'
|
||||
import styles from './create.module.css'
|
||||
|
||||
// 车型接口
|
||||
interface CarModel {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
msrp: number
|
||||
}
|
||||
|
||||
// 关键词搜索结果接口
|
||||
interface KeywordSearchResult {
|
||||
brand: string
|
||||
series: string
|
||||
series_id: string
|
||||
models: CarModel[]
|
||||
}
|
||||
|
||||
// 绑定状态接口
|
||||
interface BoundProduct {
|
||||
brand?: string
|
||||
series?: string
|
||||
series_id?: string
|
||||
model?: CarModel
|
||||
}
|
||||
|
||||
// 图片文件项接口
|
||||
interface ImageFileItem {
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
const UsedCarCreate: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({
|
||||
product_name: '',
|
||||
vin: '',
|
||||
price: '',
|
||||
source_place: '',
|
||||
plate_place: '',
|
||||
plate_time: '',
|
||||
displacement: '',
|
||||
power: '',
|
||||
body_color: '',
|
||||
condition_desc: '',
|
||||
mileage: '',
|
||||
// 绑定车型数据字段
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
|
||||
// 图片和附件
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [attachments, setAttachments] = useState<Array<{ id: number; name: string; url: string; size: number }>>([])
|
||||
|
||||
// 绑定弹窗状态
|
||||
const [bindVisible, setBindVisible] = useState(false)
|
||||
const [bindLoading, setBindLoading] = useState(false)
|
||||
const [bindResult, setBindResult] = useState<KeywordSearchResult | null>(null)
|
||||
const [bindSelected, setBindSelected] = useState<CarModel | null>(null)
|
||||
const [boundProduct, setBoundProduct] = useState<BoundProduct | null>(null)
|
||||
const [bindKeyword, setBindKeyword] = useState('')
|
||||
|
||||
// 提交中
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 上牌时间选择
|
||||
const [plateTimeVisible, setPlateTimeVisible] = useState(false)
|
||||
|
||||
// 产品名称绑定(关键词搜索)
|
||||
const handleBindByName = async () => {
|
||||
const name = formData.product_name.trim()
|
||||
if (!name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setBindVisible(true)
|
||||
setBindLoading(true)
|
||||
setBindResult(null)
|
||||
setBindSelected(null)
|
||||
setBindKeyword('')
|
||||
|
||||
try {
|
||||
const res = await usedCarApi.crawlKeyword(name)
|
||||
setBindResult(res)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.bindFetchFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setBindLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认绑定
|
||||
const handleConfirmBind = () => {
|
||||
if (!bindSelected || !bindResult) return
|
||||
|
||||
setBoundProduct({
|
||||
brand: bindResult.brand,
|
||||
series: bindResult.series,
|
||||
series_id: bindResult.series_id,
|
||||
model: bindSelected
|
||||
})
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: bindResult.brand,
|
||||
crawl_series: bindResult.series,
|
||||
crawl_series_id: bindResult.series_id,
|
||||
crawl_model_name: bindSelected.name,
|
||||
crawl_model_url: bindSelected.url,
|
||||
crawl_msrp: bindSelected.msrp ? String(bindSelected.msrp) : ''
|
||||
})
|
||||
|
||||
setBindVisible(false)
|
||||
Toast.show({ content: t('usedCar.bindSuccess'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 清除绑定
|
||||
const handleClearBind = () => {
|
||||
setBoundProduct(null)
|
||||
setFormData({
|
||||
...formData,
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_series_id: '',
|
||||
crawl_model_name: '',
|
||||
crawl_model_url: '',
|
||||
crawl_msrp: ''
|
||||
})
|
||||
Toast.show({ content: t('usedCar.bindCleared'), icon: 'success' })
|
||||
}
|
||||
|
||||
// 过滤车型列表
|
||||
const filteredModels = bindResult?.models.filter((model) => {
|
||||
const kw = bindKeyword.toLowerCase().trim()
|
||||
if (!kw) return true
|
||||
const name = String(model.name || '').toLowerCase()
|
||||
return name.includes(kw)
|
||||
}) || []
|
||||
|
||||
// 选择车型
|
||||
const handleSelectModel = (model: CarModel) => {
|
||||
setBindSelected(model)
|
||||
}
|
||||
|
||||
// 跟踪上传数量
|
||||
const [uploadingCount, setUploadingCount] = useState(0)
|
||||
|
||||
// 图片上传处理(在 ImageUploader 的 upload prop 中调用)
|
||||
const handleImageUpload = async (file: File): Promise<{ url: string }> => {
|
||||
setUploadingCount(prev => prev + 1)
|
||||
try {
|
||||
const result: any = await uploadApi.uploadUsedCarImage(file)
|
||||
return { url: result.full_url || result.url }
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
} finally {
|
||||
setUploadingCount(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片变化处理
|
||||
const handleImageChange = (newFiles: ImageFileItem[]) => {
|
||||
// 更新图片列表
|
||||
setImages(newFiles.map(f => f.url))
|
||||
}
|
||||
|
||||
// 图片删除处理
|
||||
const handleImageDelete = (deletedFile: ImageFileItem) => {
|
||||
const newImages = images.filter(url => url !== deletedFile.url)
|
||||
setImages(newImages)
|
||||
}
|
||||
|
||||
// 附件上传处理
|
||||
const handleAttachmentUpload = async (file: File): Promise<{ id: number; name: string; url: string; size: number }> => {
|
||||
try {
|
||||
const result: any = await uploadApi.uploadFile(file, 'other', 'usedcar')
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.file_name || file.name,
|
||||
url: result.url,
|
||||
size: result.file_size || file.size
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.uploadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 附件选择处理
|
||||
const handleAttachmentSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const newAttachments = [...attachments]
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (newAttachments.length + i >= 20) {
|
||||
Toast.show({ content: t('usedCar.maxAttachments'), icon: 'fail' })
|
||||
break
|
||||
}
|
||||
try {
|
||||
const result = await handleAttachmentUpload(file)
|
||||
newAttachments.push(result)
|
||||
} catch (err) {
|
||||
// 上传失败已在 handleAttachmentUpload 中处理
|
||||
}
|
||||
}
|
||||
setAttachments(newAttachments)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 附件删除处理
|
||||
const handleAttachmentDelete = (index: number) => {
|
||||
const newAttachments = attachments.filter((_, i) => i !== index)
|
||||
setAttachments(newAttachments)
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.product_name) {
|
||||
return Toast.show({ content: t('usedCar.enterProductNameFirst'), icon: 'fail' })
|
||||
}
|
||||
|
||||
// 检查是否有正在上传的图片
|
||||
if (uploadingCount > 0) {
|
||||
return Toast.show({ content: t('usedCar.uploading'), icon: 'fail' })
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
price: formData.price ? Number(formData.price) : undefined,
|
||||
mileage: formData.mileage ? Number(formData.mileage) : undefined,
|
||||
crawl_msrp: formData.crawl_msrp ? Number(formData.crawl_msrp) : undefined,
|
||||
// 图片数组转为逗号分隔字符串
|
||||
images: images.join(','),
|
||||
// 附件数组转为JSON
|
||||
attachment: attachments,
|
||||
}
|
||||
|
||||
const id = await usedCarApi.create(payload)
|
||||
if (id) {
|
||||
Toast.show({ content: t('usedCar.createSuccess'), icon: 'success' })
|
||||
setTimeout(() => {
|
||||
navigate('/used-car')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('common.error')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.createPage}>
|
||||
{/* <NavBar onBack={() => navigate(-1)}>{t('usedCar.pageTitle')}</NavBar> */}
|
||||
|
||||
<div className={styles.createContent}>
|
||||
{/* 产品名称卡片 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={`${styles.formCell} ${styles.formCellVertical}`}>
|
||||
<label className={styles.formLabel}>
|
||||
{t('usedCar.productName')}
|
||||
<span className={styles.required}>*</span>
|
||||
</label>
|
||||
<div className={styles.inputWithAction}>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterProductName')}
|
||||
value={formData.product_name}
|
||||
onChange={(value) => setFormData({ ...formData, product_name: value })}
|
||||
/>
|
||||
</div>
|
||||
<button className={styles.bindBtn} onClick={handleBindByName}>
|
||||
{t('usedCar.search')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 绑定状态提示 */}
|
||||
{boundProduct ? (
|
||||
<div className={styles.bindTip}>
|
||||
<span className={styles.bindTipText}>
|
||||
{t('usedCar.bindBound')}: {boundProduct.brand} · {boundProduct.series}
|
||||
{boundProduct.model && <span> · {boundProduct.model.name}</span>}
|
||||
</span>
|
||||
<span className={styles.bindTipClear} onClick={handleClearBind}>
|
||||
{t('usedCar.bindClear')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.bindTip}>
|
||||
<span>{t('usedCar.bindExample')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className={styles.formCard}>
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.price')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPrice')}
|
||||
value={formData.price}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, price: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.sourcePlace')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterSourcePlace')}
|
||||
value={formData.source_place}
|
||||
onChange={(value) => setFormData({ ...formData, source_place: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.plateTime')}</label>
|
||||
<div className={styles.pickerTrigger} onClick={() => setPlateTimeVisible(true)}>
|
||||
<span className={formData.plate_time ? styles.pickerValue : styles.pickerPlaceholder}>
|
||||
{formData.plate_time || t('usedCar.selectPlateTime')}
|
||||
</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={plateTimeVisible}
|
||||
onClose={() => setPlateTimeVisible(false)}
|
||||
onConfirm={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
setFormData({ ...formData, plate_time: dateStr })
|
||||
setPlateTimeVisible(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.displacement')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterDisplacement')}
|
||||
value={formData.displacement}
|
||||
onChange={(value) => setFormData({ ...formData, displacement: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.power')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterPower')}
|
||||
value={formData.power}
|
||||
onChange={(value) => setFormData({ ...formData, power: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.mileage')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterMileage')}
|
||||
value={formData.mileage}
|
||||
type="number"
|
||||
onChange={(value) => setFormData({ ...formData, mileage: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formCell}>
|
||||
<label className={styles.formLabel}>{t('usedCar.bodyColor')}</label>
|
||||
<div className={styles.admInputWrapper}>
|
||||
<Input
|
||||
placeholder={t('usedCar.enterBodyColor')}
|
||||
value={formData.body_color}
|
||||
onChange={(value) => setFormData({ ...formData, body_color: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品图 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.productImages')}</label>
|
||||
<div className={styles.imageUploadArea}>
|
||||
<ImageUploader
|
||||
value={images.map((url) => ({ url }))}
|
||||
onChange={handleImageChange}
|
||||
onDelete={handleImageDelete}
|
||||
upload={handleImageUpload}
|
||||
maxCount={20}
|
||||
multiple
|
||||
showUpload
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 附件 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.attachments')}</label>
|
||||
<div className={styles.attachmentArea}>
|
||||
<input
|
||||
type="file"
|
||||
id="attachment-upload"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleAttachmentSelect}
|
||||
/>
|
||||
<button
|
||||
className={styles.uploadBtn}
|
||||
onClick={() => document.getElementById('attachment-upload')?.click()}
|
||||
disabled={attachments.length >= 20}
|
||||
>
|
||||
{t('usedCar.uploadAttachments')}
|
||||
</button>
|
||||
<div className={styles.attachmentList}>
|
||||
{attachments.map((file, index) => (
|
||||
<div key={file.id} className={styles.attachmentItem}>
|
||||
<span className={styles.attachmentName}>{file.name}</span>
|
||||
<span
|
||||
className={styles.attachmentDelete}
|
||||
onClick={() => handleAttachmentDelete(index)}
|
||||
>
|
||||
{t('usedCar.removeImage')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 车况介绍 */}
|
||||
<div className={styles.formSection}>
|
||||
<label className={styles.formSectionLabel}>{t('usedCar.conditionDesc')}</label>
|
||||
<div className={styles.admTextAreaWrapper}>
|
||||
<TextArea
|
||||
placeholder={t('usedCar.enterConditionDesc')}
|
||||
value={formData.condition_desc}
|
||||
onChange={(value) => setFormData({ ...formData, condition_desc: value })}
|
||||
rows={4}
|
||||
autoSize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部提交按钮 */}
|
||||
<div className={styles.submitBar}>
|
||||
<Button
|
||||
block
|
||||
className={styles.submitBtn}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={uploadingCount > 0}
|
||||
>
|
||||
{uploadingCount > 0 ? t('usedCar.uploading') : t('usedCar.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 绑定车型弹窗 */}
|
||||
<Modal
|
||||
visible={bindVisible}
|
||||
content={
|
||||
<div className={styles.bindDialogContent}>
|
||||
{/* 弹窗头部 */}
|
||||
<div className={styles.bindDialogHeader}>
|
||||
<span className={styles.bindDialogTitle}>{t('usedCar.bindDialog')}</span>
|
||||
<span className={styles.bindDialogClose} onClick={() => setBindVisible(false)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 品牌/车系信息 */}
|
||||
{bindResult && (
|
||||
<div className={styles.bindBrandInfo}>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.brand')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.brand}</span>
|
||||
<span className={styles.bindBrandInfoSeparator}>·</span>
|
||||
<span className={styles.bindBrandInfoLabel}>{t('usedCar.series')}</span>
|
||||
<span className={styles.bindBrandInfoValue}>{bindResult.series}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索框 */}
|
||||
{bindResult && bindResult.models.length > 0 && (
|
||||
<div className={styles.bindSearchArea}>
|
||||
<div className={styles.bindSearch}>
|
||||
<Input
|
||||
placeholder={t('usedCar.bindSearchPlaceholder')}
|
||||
value={bindKeyword}
|
||||
onChange={(value) => setBindKeyword(value)}
|
||||
/>
|
||||
{bindKeyword && (
|
||||
<span className={styles.bindSearchClear} onClick={() => setBindKeyword('')}>
|
||||
{t('usedCar.clear')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车型列表 */}
|
||||
<div className={styles.bindList}>
|
||||
{bindLoading ? (
|
||||
<div className={styles.bindLoading}>{t('usedCar.bindSearching')}</div>
|
||||
) : !bindResult ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindSearchFirst')}</div>
|
||||
) : bindResult.models.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoModels')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className={styles.bindEmpty}>{t('usedCar.bindNoResults')}</div>
|
||||
) : (
|
||||
filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`${styles.bindItem} ${bindSelected?.id === model.id ? styles.active : ''}`}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
>
|
||||
<div className={styles.bindItemMain}>
|
||||
<div className={styles.bindItemTitle}>{model.name}</div>
|
||||
{model.msrp > 0 && (
|
||||
<div className={styles.bindItemSub}>
|
||||
{(model.msrp / 10000).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.bindItemRadio}>
|
||||
<div className={styles.bindItemRadioDot} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className={styles.bindDialogFooter}>
|
||||
<button
|
||||
className={styles.bindCancelBtn}
|
||||
onClick={() => setBindVisible(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.bindConfirmBtn}
|
||||
disabled={!bindSelected}
|
||||
onClick={handleConfirmBind}
|
||||
>
|
||||
{t('usedCar.bindConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
closeOnMaskClick
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarCreate
|
||||
@@ -0,0 +1,39 @@
|
||||
.usedCarListPage {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f8;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 搜索和筛选栏 */
|
||||
.searchFilterBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 使用 :global 包裹第三方组件类名 */
|
||||
.searchFilterBar :global(.adm-search-bar) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 列表容器 - 减少左右间距 */
|
||||
.listContainer {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
/* 覆盖 InfiniteScroll 默认样式 - 只影响当前页面 */
|
||||
.listContainer :global(.adm-infinite-scroll) {
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.listContainer :global(.adm-infinite-scroll-content) {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, SearchBar, Dropdown, Toast, InfiniteScroll, Empty } from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import CarCard from '@/components/CarCard'
|
||||
import { UsedCarItem } from '@/components/CarCard'
|
||||
import PriceModal from '@/components/PriceModal'
|
||||
import type { PriceFormData } from '@/components/PriceModal'
|
||||
import styles from './list.module.css'
|
||||
|
||||
// 二手车状态
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: '全部', value: 0 },
|
||||
{ label: '正常', value: 1 },
|
||||
{ label: '归档', value: 2 },
|
||||
]
|
||||
|
||||
const UsedCarList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [list, setList] = useState<UsedCarItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState<number>(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusVisible, setStatusVisible] = useState(false)
|
||||
|
||||
// 价格弹框相关状态
|
||||
const [priceModalVisible, setPriceModalVisible] = useState(false)
|
||||
const [currentItem, setCurrentItem] = useState<UsedCarItem | null>(null)
|
||||
|
||||
// 加载数据
|
||||
const loadMore = async (isRefresh = false) => {
|
||||
if (loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const currentPage = isRefresh ? 1 : page
|
||||
// 响应拦截器已经返回 data 部分
|
||||
const res = await usedCarApi.getList({
|
||||
type: 'all',
|
||||
page: currentPage,
|
||||
page_size: 20,
|
||||
keyword: keyword || undefined,
|
||||
status: status !== 0 ? status : undefined,
|
||||
})
|
||||
|
||||
// res 已经是 { list, total, page, page_size }
|
||||
const newList = res.list || []
|
||||
if (isRefresh) {
|
||||
setList(newList)
|
||||
setPage(2)
|
||||
} else {
|
||||
setList([...list, ...newList])
|
||||
setPage(currentPage + 1)
|
||||
}
|
||||
setHasMore(newList.length >= 20)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.loadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value)
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
const handleStatusChange = (value: number) => {
|
||||
setStatus(value)
|
||||
setStatusVisible(false)
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
const handleRefresh = () => {
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 请求价格(点击下载 PDF 但没有 PDF URL 时触发)
|
||||
const handleRequestPrice = ({ item }: { item: UsedCarItem; index: number }) => {
|
||||
setCurrentItem(item)
|
||||
setPriceModalVisible(true)
|
||||
}
|
||||
|
||||
// 确认价格并生成 PDF
|
||||
const handlePriceConfirm = async (data: PriceFormData) => {
|
||||
if (!currentItem) return
|
||||
|
||||
// 通过全局方法调用 CarCard 的 PDF 生成函数
|
||||
const generateFunc = (window as { carCardDownloadPdf?: (data: PriceFormData) => Promise<void> }).carCardDownloadPdf
|
||||
if (generateFunc && typeof generateFunc === 'function') {
|
||||
await generateFunc(data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadMore(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.usedCarListPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.title')}</NavBar>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className={styles.searchFilterBar}>
|
||||
<SearchBar
|
||||
placeholder={t('usedCar.searchPlaceholder')}
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Dropdown
|
||||
visible={statusVisible}
|
||||
onClose={() => setStatusVisible(false)}
|
||||
trigger="click"
|
||||
>
|
||||
<Dropdown.Item key="status" title={STATUS_OPTIONS.find(s => s.value === status)?.label || t('usedCar.status')}>
|
||||
<div style={{ padding: '12px' }}>
|
||||
{STATUS_OPTIONS.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleStatusChange(option.value)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: status === option.value ? '#f5f5f5' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className={styles.listContainer}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description={t('usedCar.empty')} />
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
loadMore={loadMore}
|
||||
hasMore={hasMore}
|
||||
>
|
||||
{list.map((item, index) => (
|
||||
<CarCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onRefresh={handleRefresh}
|
||||
onRequestPrice={handleRequestPrice}
|
||||
/>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 价格输入弹框 */}
|
||||
<PriceModal
|
||||
visible={priceModalVisible}
|
||||
onClose={() => setPriceModalVisible(false)}
|
||||
onConfirm={handlePriceConfirm}
|
||||
item={currentItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsedCarList
|
||||
@@ -0,0 +1,131 @@
|
||||
.mine {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* User Info Card */
|
||||
.userCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #3F435B;
|
||||
margin: 12px;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(63, 67, 91, 0.2);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarText {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.userNameEn {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.userPhone {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.userArrow {
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Menu */
|
||||
.menu {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
margin: 0 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menuItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menuItem:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.menuLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.menuLabel {
|
||||
font-size: 15px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.menuArrow {
|
||||
font-size: 16px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
/* Bottom Safe Area */
|
||||
.safeArea {
|
||||
height: 20px;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, Toast } from 'antd-mobile'
|
||||
import { userApi } from '@/services/api'
|
||||
import styles from './mine.module.css'
|
||||
|
||||
interface MenuItem {
|
||||
id: string
|
||||
icon: string
|
||||
label: string
|
||||
path?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: number
|
||||
name: string
|
||||
name_en?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
role: number // 1=Admin, 2=User
|
||||
company_id?: number
|
||||
}
|
||||
|
||||
export default function UsedCarMine() {
|
||||
const navigate = useNavigate()
|
||||
const { user: storeUser } = useUserStore()
|
||||
const { t } = useTranslation()
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo()
|
||||
}, [])
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await userApi.getInfo()
|
||||
setUserInfo(data)
|
||||
} catch (error: any) {
|
||||
Toast.show({ content: error.message || t('common.error') })
|
||||
// If API fails, use store user data
|
||||
setUserInfo({
|
||||
id: storeUser?.id || 0,
|
||||
name: storeUser?.name || '',
|
||||
phone: storeUser?.phone,
|
||||
email: storeUser?.email,
|
||||
avatar: storeUser?.avatar,
|
||||
role: 2, // Default to normal user
|
||||
company_id: storeUser?.company_id,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'my-publish',
|
||||
icon: '🚗',
|
||||
label: userInfo?.role === 1 ? t('usedCar.myGarage') : t('usedCar.myPublish'),
|
||||
path: '/used-car/my-list',
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
icon: '👤',
|
||||
label: t('usedCar.personalInfo'),
|
||||
onClick: () => navigate('/used-car/profile'),
|
||||
},
|
||||
]
|
||||
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
if (item.path) {
|
||||
navigate(item.path)
|
||||
} else if (item.onClick) {
|
||||
item.onClick()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserCardClick = () => {
|
||||
navigate('/used-car/profile')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.mine}>
|
||||
<NavBar back={null}>{t('usedCar.myPage')}</NavBar>
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.mine}>
|
||||
<NavBar back={null}>{t('usedCar.myPage')}</NavBar>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className={styles.userCard} onClick={handleUserCardClick}>
|
||||
<div className={styles.avatar}>
|
||||
{userInfo?.avatar ? (
|
||||
<img src={userInfo.avatar} alt={userInfo.name} className={styles.avatarImg} />
|
||||
) : (
|
||||
<span className={styles.avatarText}>{userInfo?.name?.[0] || 'U'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.userName}>{userInfo?.name || t('login.username')}</div>
|
||||
{userInfo?.name_en && <div className={styles.userNameEn}>{userInfo.name_en}</div>}
|
||||
{userInfo?.phone && <div className={styles.userPhone}>{userInfo.phone}</div>}
|
||||
</div>
|
||||
<div className={styles.userArrow}>›</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className={styles.menu}>
|
||||
{menuItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={styles.menuItem}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
>
|
||||
<div className={styles.menuLeft}>
|
||||
<span className={styles.menuIcon}>{item.icon}</span>
|
||||
<span className={styles.menuLabel}>{item.label}</span>
|
||||
</div>
|
||||
<span className={styles.menuArrow}>›</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Safe Area */}
|
||||
<div className={styles.safeArea} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, SearchBar, Dropdown, Toast, InfiniteScroll, Empty } from 'antd-mobile'
|
||||
import { usedCarApi } from '@/services/api'
|
||||
import CarCard from '@/components/CarCard'
|
||||
import { UsedCarItem } from '@/components/CarCard'
|
||||
import PriceModal from '@/components/PriceModal'
|
||||
import type { PriceFormData } from '@/components/PriceModal'
|
||||
import styles from './list.module.css'
|
||||
|
||||
const MyPublishList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 二手车状态
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: t('common.all'), value: 0 },
|
||||
{ label: t('usedCar.statusNormal'), value: 1 },
|
||||
{ label: t('usedCar.statusArchived'), value: 2 },
|
||||
{ label: t('usedCar.statusSold'), value: 4 },
|
||||
]
|
||||
|
||||
const [list, setList] = useState<UsedCarItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState<number>(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusVisible, setStatusVisible] = useState(false)
|
||||
|
||||
// 价格弹框相关状态
|
||||
const [priceModalVisible, setPriceModalVisible] = useState(false)
|
||||
const [currentItem, setCurrentItem] = useState<UsedCarItem | null>(null)
|
||||
|
||||
// 加载数据
|
||||
const loadMore = async (isRefresh = false) => {
|
||||
if (loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const currentPage = isRefresh ? 1 : page
|
||||
// 响应拦截器已经返回 data 部分
|
||||
const res = await usedCarApi.getList({
|
||||
type: 'self', // 只获取当前用户的数据
|
||||
page: currentPage,
|
||||
page_size: 20,
|
||||
keyword: keyword || undefined,
|
||||
status: status !== 0 ? status : undefined,
|
||||
})
|
||||
|
||||
// res 已经是 { list, total, page, page_size }
|
||||
const newList = res.list || []
|
||||
if (isRefresh) {
|
||||
setList(newList)
|
||||
setPage(2)
|
||||
} else {
|
||||
setList([...list, ...newList])
|
||||
setPage(currentPage + 1)
|
||||
}
|
||||
setHasMore(newList.length >= 20)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('usedCar.loadFailed')
|
||||
Toast.show({ content: message, icon: 'fail' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value)
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
const handleStatusChange = (value: number) => {
|
||||
setStatus(value)
|
||||
setStatusVisible(false)
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
const handleRefresh = () => {
|
||||
loadMore(true)
|
||||
}
|
||||
|
||||
// 请求价格(点击下载 PDF 但没有 PDF URL 时触发)
|
||||
const handleRequestPrice = ({ item }: { item: UsedCarItem; index: number }) => {
|
||||
setCurrentItem(item)
|
||||
setPriceModalVisible(true)
|
||||
}
|
||||
|
||||
// 确认价格并生成 PDF
|
||||
const handlePriceConfirm = async (data: PriceFormData) => {
|
||||
if (!currentItem) return
|
||||
|
||||
// 通过全局方法调用 CarCard 的 PDF 生成函数
|
||||
const generateFunc = (window as { carCardDownloadPdf?: (data: PriceFormData) => Promise<void> }).carCardDownloadPdf
|
||||
if (generateFunc && typeof generateFunc === 'function') {
|
||||
await generateFunc(data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadMore(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.usedCarListPage}>
|
||||
<NavBar onBack={() => navigate(-1)}>{t('usedCar.myPublish')}</NavBar>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className={styles.searchFilterBar}>
|
||||
<SearchBar
|
||||
placeholder={t('usedCar.searchPlaceholder')}
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Dropdown
|
||||
visible={statusVisible}
|
||||
onClose={() => setStatusVisible(false)}
|
||||
trigger="click"
|
||||
>
|
||||
<Dropdown.Item key="status" title={STATUS_OPTIONS.find(s => s.value === status)?.label || t('usedCar.status')}>
|
||||
<div style={{ padding: '12px' }}>
|
||||
{STATUS_OPTIONS.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleStatusChange(option.value)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: status === option.value ? '#f5f5f5' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className={styles.listContainer}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description={t('usedCar.emptyMyPublish')} />
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
loadMore={loadMore}
|
||||
hasMore={hasMore}
|
||||
>
|
||||
{list.map((item, index) => (
|
||||
<CarCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isMy={true}
|
||||
index={index}
|
||||
onRefresh={handleRefresh}
|
||||
onRequestPrice={handleRequestPrice}
|
||||
/>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 价格输入弹框 */}
|
||||
<PriceModal
|
||||
visible={priceModalVisible}
|
||||
onClose={() => setPriceModalVisible(false)}
|
||||
onConfirm={handlePriceConfirm}
|
||||
item={currentItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyPublishList
|
||||
Reference in New Issue
Block a user