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:
浅念
2026-03-11 16:11:49 +08:00
parent 8a056e2295
commit baa424c841
28 changed files with 6235 additions and 8 deletions
@@ -0,0 +1,190 @@
.carCardWrapper {
border-radius: 14px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.carCardWrapper:active {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
}
.cardHeader {
padding: 8px 12px;
color: #666;
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafafa;
}
.cardCode {
color: #666;
font-size: 12px;
}
.editBtn {
padding: 4px 8px;
font-size: 16px;
color: #3F435B;
cursor: pointer;
background: transparent;
border: none;
}
.carCard {
position: relative;
display: flex;
align-items: stretch;
padding: 12px;
}
.thumb {
width: 100px;
height: 75px;
border-radius: 10px;
background: #eef2f7;
flex-shrink: 0;
overflow: hidden;
}
.thumbImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.info {
flex: 1;
margin-left: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}
.row {
display: flex;
align-items: center;
}
.row1 {
justify-content: space-between;
margin-bottom: 6px;
}
.name {
font-size: 15px;
font-weight: 700;
color: #111827;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.price {
font-size: 16px;
font-weight: 700;
color: #ff6b35;
flex-shrink: 0;
margin-left: 8px;
}
.row2,
.row3 {
justify-content: space-between;
gap: 8px;
}
.kv {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
}
.k {
font-size: 11px;
color: #6b7280;
margin-right: 4px;
flex-shrink: 0;
}
.v {
font-size: 12px;
color: #374151;
font-weight: 600;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cardActions {
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
}
.actionsLeft {
font-size: 12px;
color: #999;
}
.actionsRight {
display: flex;
gap: 8px;
}
.actionBtn {
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: none;
padding: 0;
transition: all 0.2s;
font-size: 16px;
}
.actionBtn:active {
transform: scale(0.92);
background: #f5f5f5;
}
.statusTag {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.statusNormal {
background: rgba(82, 196, 26, 0.1);
color: #52c41a;
}
.statusArchived {
background: rgba(140, 140, 140, 0.1);
color: #8c8c8c;
}
.statusSold {
background: rgba(250, 173, 20, 0.1);
color: #faad14;
}
+342
View File
@@ -0,0 +1,342 @@
import { useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Image, Toast, Dialog } from 'antd-mobile'
import { usedCarApi } from '@/services/api'
import styles from './CarCard.module.css'
import type { PriceFormData } from '@/components/PriceModal'
export interface UsedCarItem {
id: number
code: string
product_name: string
source_place?: string
body_color?: string
plate_time?: string
mileage?: number
images?: string
images_pull?: Array<{ url: string }>
status: number
created_at: string
pdf_src?: string
price?: number
}
interface CarCardProps {
item: UsedCarItem
isMy?: boolean
index?: number
onRefresh?: () => void
onRequestPrice?: (data: { item: UsedCarItem; index: number }) => void
}
const CarCard: React.FC<CarCardProps> = ({ item, isMy = false, index = -1, onRefresh, onRequestPrice }) => {
const { t } = useTranslation()
const navigate = useNavigate()
// 格式化上牌时间
const formatPlateTime = (dateStr?: string) => {
if (!dateStr) return '-'
return String(dateStr).slice(0, 7)
}
// 获取图片
const getImages = (item: UsedCarItem): string[] => {
if (item.images_pull && item.images_pull.length > 0) {
return item.images_pull.map(img => img.url)
}
if (item.images) {
try {
const images = JSON.parse(item.images)
return Array.isArray(images) ? images : []
} catch {
return []
}
}
return []
}
// 点击卡片跳转详情
const handleCardClick = () => {
if (item?.id) {
navigate(`/used-car/detail/${item.id}`)
}
}
// 编辑
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
navigate(`/used-car/edit/${item.id}`)
}
// H5 下载:用 a 标签触发
const downloadByAnchor = (url: string, filename?: string) => {
try {
const a = document.createElement('a')
a.href = url
if (filename) {
a.download = filename
}
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch {
// 兜底:直接打开
window.open(url, '_blank')
}
}
// 下载PDF - 如果存在 URL 直接下载,否则请求价格
const handleDownloadPdf = async (e: React.MouseEvent) => {
e.stopPropagation()
// 如果有 PDF URL,直接下载
if (item.pdf_src && item.pdf_src.trim() !== '') {
downloadByAnchor(item.pdf_src, `${item.product_name}-${item.code}.pdf`)
return
}
// 没有 PDF URL,触发价格输入弹框
if (onRequestPrice) {
onRequestPrice({ item, index })
}
}
// 生成并下载 PDF(由价格弹框确认后调用)
const generateAndDownloadPdf = useCallback(async (priceData: PriceFormData) => {
try {
Toast.show({ content: '正在生成 PDF...', icon: 'loading' })
// 调用生成 PDF 接口
const res = await usedCarApi.generatePdf({
id: item.id,
price: Number(priceData.price),
currency: priceData.currency,
lang: priceData.lang,
trade_term: priceData.trade_term
}) as { pdf_url?: string; file_name?: string }
// 生成成功,下载 PDF
if (res.pdf_url) {
downloadByAnchor(res.pdf_url, res.file_name || `${item.product_name}-${item.code}.pdf`)
Toast.show({ content: t('usedCar.pdfDownloadSuccess') || 'PDF 下载成功', icon: 'success' })
// 刷新列表
onRefresh?.()
}
} catch (error) {
const message = error instanceof Error ? error.message : (t('usedCar.pdfDownloadFailed') || 'PDF 生成失败')
Toast.show({ content: message, icon: 'fail' })
throw error
}
}, [item.id, item.product_name, item.code, t, onRefresh])
// 导出方法供外部调用
useEffect(() => {
if (window) {
(window as any).carCardDownloadPdf = generateAndDownloadPdf
return () => { delete (window as any).carCardDownloadPdf }
}
}, [generateAndDownloadPdf])
// 等待飞书 tt 对象准备就绪
const waitForFeishuSDK = (): Promise<void> => {
return new Promise((resolve, reject) => {
// 如果已经存在,直接返回
if (window.tt && (window as any).h5sdk) {
;(window as any).h5sdk.ready(() => {
resolve()
})
return
}
// 轮询等待 tt 对象
let attempts = 0
const maxAttempts = 50
const checkInterval = setInterval(() => {
attempts++
if (window.tt && (window as any).h5sdk) {
clearInterval(checkInterval)
;(window as any).h5sdk.ready(() => {
resolve()
})
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval)
reject(new Error('TT_OBJECT_TIMEOUT'))
}
}, 100)
})
}
// 分享
const handleShare = async (e: React.MouseEvent) => {
e.stopPropagation()
const title = item.product_name || '车辆详情'
const link = `${window.location.origin}/#/used-car/detail/${item.id}`
const images = getImages(item)
// 检测是否在飞书环境
const isFeishuEnv = /feishu|lark/i.test(navigator.userAgent)
if (isFeishuEnv) {
try {
// 等待飞书 SDK 准备就绪
await waitForFeishuSDK()
// 调用飞书分享
;(window as any).tt.shareWebContent({
title,
content: title,
url: link,
imageUrl: images[0] || '',
success: () => {
Toast.show({ content: '已唤起飞书分享', icon: 'success' })
},
fail: (err: any) => {
console.error('飞书分享失败:', err)
// 飞书分享失败,尝试复制链接
navigator.clipboard.writeText(link).then(() => {
Toast.show({ content: '链接已复制', icon: 'success' })
}).catch(() => {
Toast.show({ content: '复制失败', icon: 'fail' })
})
}
})
} catch (error) {
console.error('飞书 SDK 加载失败:', error)
// SDK 加载失败,尝试 Web Share API 或复制链接
if (navigator.share) {
navigator.share({ title, text: title, url: link }).catch(() => {
navigator.clipboard.writeText(link).then(() => {
Toast.show({ content: '链接已复制', icon: 'success' })
})
})
} else {
navigator.clipboard.writeText(link).then(() => {
Toast.show({ content: '链接已复制', icon: 'success' })
}).catch(() => {
Toast.show({ content: '复制失败', icon: 'fail' })
})
}
}
return
}
// 非飞书环境,使用 Web Share API
if (navigator.share) {
navigator.share({ title, text: title, url: link }).catch(() => {
navigator.clipboard.writeText(link).then(() => {
Toast.show({ content: '链接已复制', icon: 'success' })
}).catch(() => {
Toast.show({ content: '复制失败', icon: 'fail' })
})
})
return
}
// 最后的兜底:复制链接
navigator.clipboard.writeText(link).then(() => {
Toast.show({ content: '链接已复制', icon: 'success' })
}).catch(() => {
Toast.show({ content: '复制失败', icon: 'fail' })
})
}
// 标记已售出
const handleMarkSold = (e: React.MouseEvent) => {
e.stopPropagation()
Dialog.confirm({
content: t('usedCar.confirmMarkSold'),
onConfirm: async () => {
try {
await usedCarApi.updateStatus(item.id, 2)
Toast.show({ content: '已标记为售出', icon: 'success' })
onRefresh?.()
} catch (error) {
const message = error instanceof Error ? error.message : '操作失败'
Toast.show({ content: message, icon: 'fail' })
}
}
})
}
const images = getImages(item)
return (
<div className={styles.carCardWrapper}>
{/* 头部 */}
<div className={styles.cardHeader}>
<span className={styles.cardCode}>
{t('usedCar.code')}: {item.code || '~'}
</span>
{isMy && (
<button className={styles.editBtn} onClick={handleEdit}>
✏️
</button>
)}
</div>
{/* 卡片主体 */}
<div className={styles.carCard} onClick={handleCardClick}>
{/* 缩略图 */}
{images.length > 0 && (
<div className={styles.thumb}>
<Image src={images[0]} fit="cover" className={styles.thumbImage} />
</div>
)}
{/* 信息 */}
<div className={styles.info}>
<div className={styles.row1}>
<span className={styles.name}>{item.product_name}</span>
{item.price && <span className={styles.price}>¥{item.price}</span>}
</div>
<div className={styles.row2}>
<div className={styles.kv}>
<span className={styles.k}>{t('usedCar.sourcePlace')}</span>
<span className={styles.v}>{item.source_place || '-'}</span>
</div>
<div className={styles.kv}>
<span className={styles.k}>{t('usedCar.bodyColor')}</span>
<span className={styles.v}>{item.body_color || '-'}</span>
</div>
</div>
<div className={styles.row3}>
<div className={styles.kv}>
<span className={styles.k}>{t('usedCar.plateTime')}</span>
<span className={styles.v}>{formatPlateTime(item.plate_time)}</span>
</div>
<div className={styles.kv}>
<span className={styles.k}>{t('usedCar.mileage')}</span>
<span className={styles.v}>{item.mileage ? `${item.mileage} Km` : '-'}</span>
</div>
</div>
</div>
</div>
{/* 底部操作 */}
<div className={styles.cardActions}>
<div className={styles.actionsLeft}>
<span>{t('usedCar.createdAt')}: {item.created_at}</span>
</div>
<div className={styles.actionsRight}>
<button className={styles.actionBtn} onClick={handleDownloadPdf}>
📥
</button>
<button className={styles.actionBtn} onClick={handleShare}>
🔗
</button>
{isMy && item.status === 1 && (
<button className={styles.actionBtn} onClick={handleMarkSold}>
✓
</button>
)}
</div>
</div>
</div>
)
}
export default CarCard
+2
View File
@@ -0,0 +1,2 @@
export { default } from './CarCard'
export type { UsedCarItem } from './CarCard'
@@ -0,0 +1,25 @@
/* 价格弹框容器 - 覆盖 antd-mobile 默认样式 */
.modalWrapper :global(.adm-center-popup-wrap) {
width: 85%;
max-width: 420px;
min-width: unset;
}
/* 弹框内容区域 */
.modalWrapper :global(.adm-modal-body) {
width: 100%;
border-radius: 16px;
overflow: hidden;
}
.modalWrapper :global(.adm-modal-content) {
padding: 0;
}
/* 确保弹框在移动端有合适的宽度 */
@media (max-width: 480px) {
.modalWrapper :global(.adm-center-popup-wrap) {
width: 90%;
max-width: none;
}
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Modal, Input, Toast } from 'antd-mobile'
import { usedCarApi } from '@/services/api'
interface PriceModalProps {
visible: boolean
onClose: () => void
onConfirm: (data: PriceFormData) => void
item?: any
}
export interface PriceFormData {
trade_term: string
price: string
currency: string
lang: string
}
// 贸易条款选项
const TRADE_TERM_OPTIONS = [
{ value: 'FOB', label: 'FOB' },
{ value: 'CIF', label: 'CIF' },
{ value: 'EXW', label: 'EXW' },
{ value: 'DDP', label: 'DDP' },
{ value: 'CIP', label: 'CIP' },
{ value: 'FCA', label: 'FCA' },
{ value: 'CFR', label: 'CFR' }
]
// 币种选项
const CURRENCY_OPTIONS = [
{ value: 'USD', label: '美元' },
{ value: 'CNY', label: '人民币' },
{ value: 'RUB', label: '卢布' },
{ value: 'AED', label: '迪拉姆' },
{ value: 'EUR', label: '欧元' }
]
// 语言选项
const LANG_OPTIONS = [
{ value: 'en', label: '英语' },
{ value: 'ru', label: '俄语' },
{ value: 'es', label: '西班牙语' },
{ value: 'ar', label: '阿拉伯语' }
]
const PriceModal: React.FC<PriceModalProps> = ({ visible, onClose, onConfirm, item }) => {
const { t } = useTranslation()
const [formData, setFormData] = useState<PriceFormData>({
trade_term: 'FOB',
price: '',
currency: 'USD',
lang: 'en'
})
const [loading, setLoading] = useState(false)
// 注入全局样式 - 只在组件挂载时执行一次
useEffect(() => {
const styleId = 'price-modal-override-style'
let styleEl = document.getElementById(styleId) as HTMLStyleElement
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = styleId
styleEl.innerHTML = `
.adm-center-popup-wrap {
width: 85% !important;
max-width: 420px !important;
min-width: unset !important;
}
@media (max-width: 480px) {
.adm-center-popup-wrap {
width: 90% !important;
max-width: none !important;
}
}
`
document.head.appendChild(styleEl)
}
return () => {
styleEl?.remove()
}
}, [])
// 重置表单
useEffect(() => {
if (visible) {
setFormData({
trade_term: 'FOB',
price: '',
currency: 'USD',
lang: 'en'
})
}
}, [visible])
// 价格输入处理
const handlePriceInput = (value: string) => {
// 只允许数字和小数点
const cleanValue = value.replace(/[^\d.]/g, '')
// 只保留第一个小数点
const parts = cleanValue.split('.')
if (parts.length > 2) {
const finalValue = parts[0] + '.' + parts.slice(1).join('')
setFormData({ ...formData, price: finalValue })
} else {
setFormData({ ...formData, price: cleanValue })
}
}
// 确认生成
const handleConfirm = async () => {
if (!formData.trade_term) {
return Toast.show({ content: t('usedCar.selectTradeTerm') || '请选择贸易条款', icon: 'fail' })
}
if (!formData.price) {
return Toast.show({ content: t('usedCar.enterPrice') || '请输入价格', icon: 'fail' })
}
setLoading(true)
try {
await onConfirm(formData)
onClose()
} catch (error) {
console.error('生成 PDF 失败:', error)
} finally {
setLoading(false)
}
}
return (
<Modal
visible={visible}
content={
<div style={{ padding: '24px 20px 20px' }}>
{/* 标题 */}
<div style={{ fontSize: '18px', fontWeight: 600, marginBottom: '20px', textAlign: 'center' }}>
{t('usedCar.setPrice') || '设置价格'}
</div>
{/* 贸易条款 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '14px', color: '#666', marginBottom: '8px' }}>
{t('usedCar.tradeTerm') || '贸易条款'}
</label>
<select
value={formData.trade_term}
onChange={(e) => setFormData({ ...formData, trade_term: e.target.value })}
style={{
width: '100%',
padding: '10px',
border: '1px solid #ddd',
borderRadius: '8px',
fontSize: '14px'
}}
>
<option value="">请选择</option>
{TRADE_TERM_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* 价格 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '14px', color: '#666', marginBottom: '8px' }}>
{t('usedCar.price') || '价格'}
</label>
<Input
placeholder={t('usedCar.enterPrice') || '请输入价格'}
value={formData.price}
onChange={handlePriceInput}
type="number"
inputMode="decimal"
/>
</div>
{/* 币种 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '14px', color: '#666', marginBottom: '8px' }}>
{t('usedCar.currency') || '币种'}
</label>
<select
value={formData.currency}
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
style={{
width: '100%',
padding: '10px',
border: '1px solid #ddd',
borderRadius: '8px',
fontSize: '14px'
}}
>
{CURRENCY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* 语言 */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '14px', color: '#666', marginBottom: '8px' }}>
{t('usedCar.language') || '语言'}
</label>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
{LANG_OPTIONS.map(opt => (
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input
type="radio"
name="lang"
value={opt.value}
checked={formData.lang === opt.value}
onChange={(e) => setFormData({ ...formData, lang: e.target.value })}
/>
<span style={{ fontSize: '14px' }}>{opt.label}</span>
</label>
))}
</div>
</div>
{/* 按钮 */}
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={onClose}
style={{
flex: 1,
padding: '12px',
borderRadius: '8px',
background: '#f5f5f5',
color: '#333',
fontSize: '16px',
fontWeight: 500,
border: 'none',
cursor: 'pointer'
}}
>
{t('common.cancel') || '取消'}
</button>
<button
onClick={handleConfirm}
disabled={loading}
style={{
flex: 1,
padding: '12px',
borderRadius: '8px',
background: loading ? '#ccc' : '#3F435B',
color: '#fff',
fontSize: '16px',
fontWeight: 600,
border: 'none',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? (t('common.loading') || '生成中...') : (t('common.confirm') || '确认')}
</button>
</div>
</div>
}
closeOnMaskClick={false}
onClose={onClose}
/>
)
}
export default PriceModal
@@ -0,0 +1,223 @@
const fs = require('fs');
const path = require('path');
// 新增的翻译键
const newTranslations = {
zh: {
pageTitle: '二手车录入',
bind: '绑定',
bindProduct: '绑定产品',
bindDialog: '选择要绑定的产品',
bindSearchPlaceholder: '搜索产品名称',
bindLoading: '加载中…',
bindEmpty: '未找到匹配的产品',
bindConfirm: '确认绑定',
bindClear: '清除',
bindBound: '已绑定:',
bindSuccess: '绑定成功',
bindCleared: '已清除绑定',
enterProductName: '请输入车辆的品牌 系列查找车型',
enterProductNameFirst: '请先输入产品名称',
bindFetchFailed: '拉取产品失败,请稍后重试',
bindExample: '例:比亚迪海狮07DM',
enterPrice: '请输入车价',
enterSourcePlace: '请输入车源地',
selectPlateTime: '请选择时间',
enterDisplacement: '请输入排量',
enterPower: '请输入马力',
enterMileage: '请输入里程',
enterBodyColor: '请输入车身颜色',
enterConditionDesc: '请输入车况介绍',
uploadImages: '上传产品图',
uploadAttachments: '上传附件',
maxImages: '最多上传20张图片',
maxAttachments: '最多上传20个附件',
uploading: '上传中...',
uploadFailed: '上传失败',
removeImage: '删除',
view: '查看',
requiredField: '必填项',
fillRequiredFields: '请填写所有必填项',
confirmSubmit: '确认提交?'
},
en: {
pageTitle: 'Add Used Car',
bind: 'Bind',
bindProduct: 'Bind Product',
bindDialog: 'Select Product to Bind',
bindSearchPlaceholder: 'Search product name',
bindLoading: 'Loading…',
bindEmpty: 'No matching products found',
bindConfirm: 'Confirm Bind',
bindClear: 'Clear',
bindBound: 'Bound: ',
bindSuccess: 'Bind successful',
bindCleared: 'Bind cleared',
enterProductName: 'Enter vehicle brand and series to find model',
enterProductNameFirst: 'Please enter product name first',
bindFetchFailed: 'Failed to fetch products, please try again later',
bindExample: 'Ex: BYD Seal 07 DM',
enterPrice: 'Enter price',
enterSourcePlace: 'Enter source place',
selectPlateTime: 'Select time',
enterDisplacement: 'Enter displacement',
enterPower: 'Enter power',
enterMileage: 'Enter mileage',
enterBodyColor: 'Enter body color',
enterConditionDesc: 'Enter condition description',
uploadImages: 'Upload Images',
uploadAttachments: 'Upload Attachments',
maxImages: 'Maximum 20 images',
maxAttachments: 'Maximum 20 attachments',
uploading: 'Uploading...',
uploadFailed: 'Upload failed',
removeImage: 'Remove',
view: 'View',
requiredField: 'Required',
fillRequiredFields: 'Please fill all required fields',
confirmSubmit: 'Confirm submit?'
},
ru: {
pageTitle: 'Добавить подержанный автомобиль',
bind: 'Привязать',
bindProduct: 'Привязать продукт',
bindDialog: 'Выберите продукт для привязки',
bindSearchPlaceholder: 'Поиск по названию',
bindLoading: 'Загрузка…',
bindEmpty: 'Продукты не найдены',
bindConfirm: 'Подтвердить',
bindClear: 'Очистить',
bindBound: 'Привязано: ',
bindSuccess: 'Привязка успешна',
bindCleared: 'Привязка очищена',
enterProductName: 'Введите бренд и серию автомобиля',
enterProductNameFirst: 'Сначала введите название продукта',
bindFetchFailed: 'Не удалось получить продукты, попробуйте позже',
bindExample: 'Пример: BYD Seal 07 DM',
enterPrice: 'Введите цену',
enterSourcePlace: 'Введите место происхождения',
selectPlateTime: 'Выберите время',
enterDisplacement: 'Введите объем двигателя',
enterPower: 'Введите мощность',
enterMileage: 'Введите пробег',
enterBodyColor: 'Введите цвет кузова',
enterConditionDesc: 'Введите описание состояния',
uploadImages: 'Загрузить изображения',
uploadAttachments: 'Загрузить вложения',
maxImages: 'Максимум 20 изображений',
maxAttachments: 'Максимум 20 вложений',
uploading: 'Загрузка...',
uploadFailed: 'Загрузка не удалась',
removeImage: 'Удалить',
view: 'Просмотр',
requiredField: 'Обязательно',
fillRequiredFields: 'Заполните все обязательные поля',
confirmSubmit: 'Подтвердить отправку?'
},
es: {
pageTitle: 'Añadir Coche Usado',
bind: 'Vincular',
bindProduct: 'Vincular Producto',
bindDialog: 'Seleccionar Producto para Vincular',
bindSearchPlaceholder: 'Buscar nombre del producto',
bindLoading: 'Cargando…',
bindEmpty: 'No se encontraron productos coincidentes',
bindConfirm: 'Confirmar Vinculación',
bindClear: 'Limpiar',
bindBound: 'Vinculado: ',
bindSuccess: 'Vinculación exitosa',
bindCleared: 'Vinculación borrada',
enterProductName: 'Ingrese la marca y serie del vehículo',
enterProductNameFirst: 'Primero ingrese el nombre del producto',
bindFetchFailed: 'Error al obtener productos, inténtelo más tarde',
bindExample: 'Ej: BYD Seal 07 DM',
enterPrice: 'Ingrese el precio',
enterSourcePlace: 'Ingrese el lugar de origen',
selectPlateTime: 'Seleccionar tiempo',
enterDisplacement: 'Ingrese el desplazamiento',
enterPower: 'Ingrese la potencia',
enterMileage: 'Ingrese el kilometraje',
enterBodyColor: 'Ingrese el color de la carrocería',
enterConditionDesc: 'Ingrese la descripción del estado',
uploadImages: 'Subir Imágenes',
uploadAttachments: 'Subir Adjuntos',
maxImages: 'Máximo 20 imágenes',
maxAttachments: 'Máximo 20 adjuntos',
uploading: 'Subiendo...',
uploadFailed: 'Error al subir',
removeImage: 'Eliminar',
view: 'Ver',
requiredField: 'Requerido',
fillRequiredFields: 'Complete todos los campos requeridos',
confirmSubmit: '¿Confirmar envío?'
},
fr: {
pageTitle: 'Ajouter une Voiture d\'occasion',
bind: 'Lier',
bindProduct: 'Lier le Produit',
bindDialog: 'Sélectionner un Produit à Lier',
bindSearchPlaceholder: 'Rechercher nom du produit',
bindLoading: 'Chargement…',
bindEmpty: 'Aucun produit correspondant trouvé',
bindConfirm: 'Confirmer la Liaison',
bindClear: 'Effacer',
bindBound: 'Lié : ',
bindSuccess: 'Liaison réussie',
bindCleared: 'Liaison effacée',
enterProductName: 'Entrez la marque et la série du véhicule',
enterProductNameFirst: 'Veuillez d\'abord entrer le nom du produit',
bindFetchFailed: 'Échec de la récupération des produits, réessayez plus tard',
bindExample: 'Ex: BYD Seal 07 DM',
enterPrice: 'Entrez le prix',
enterSourcePlace: 'Entrez le lieu d\'origine',
selectPlateTime: 'Sélectionner l\'heure',
enterDisplacement: 'Entrez la cylindrée',
enterPower: 'Entrez la puissance',
enterMileage: 'Entrez le kilométrage',
enterBodyColor: 'Entrez la couleur de la carrosserie',
enterConditionDesc: 'Entrez la description de l\'état',
uploadImages: 'Télécharger des Images',
uploadAttachments: 'Télécharger des Pièces Jointes',
maxImages: 'Maximum 20 images',
maxAttachments: 'Maximum 20 pièces jointes',
uploading: 'Téléchargement...',
uploadFailed: 'Échec du téléchargement',
removeImage: 'Supprimer',
view: 'Voir',
requiredField: 'Obligatoire',
fillRequiredFields: 'Veuillez remplir tous les champs obligatoires',
confirmSubmit: 'Confirmer l\'envoi ?'
}
};
// 语言列表
const languages = ['zh', 'en', 'ru', 'es', 'fr'];
const localesDir = path.join(__dirname);
// 更新每个语言文件
languages.forEach(lang => {
const filePath = path.join(localesDir, `${lang}.json`);
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// 添加 usedCar 的新键(如果不存在)
if (!content.usedCar) {
content.usedCar = {};
}
const newKeys = newTranslations[lang];
Object.keys(newKeys).forEach(key => {
if (!(key in content.usedCar)) {
content.usedCar[key] = newKeys[key];
}
});
// 添加 common.clear (如果不存在)
if (!content.common.clear) {
content.common.clear = newTranslations[lang].clear || '清除';
}
fs.writeFileSync(filePath, JSON.stringify(content, null, 2), 'utf8');
console.log(`Updated ${lang}.json`);
});
console.log('All translation files updated successfully!');
+102 -2
View File
@@ -29,7 +29,8 @@
"status": "Status",
"remark": "Remark",
"createTime": "Create Time",
"updateTime": "Update Time"
"updateTime": "Update Time",
"clear": "Clear"
},
"dashboard": {
"welcome": "Welcome Back",
@@ -186,5 +187,104 @@
"lastMonth": "Last Month",
"thisYear": "This Year",
"lastYear": "Last Year"
},
"usedCar": {
"title": "Used Car",
"code": "Code",
"search": "Search",
"searchPlaceholder": "Search vehicle name",
"status": "Status",
"statusNormal": "Normal",
"statusArchived": "Archived",
"statusSold": "Sold",
"markSold": "Mark as Sold",
"downloadPdf": "Download PDF",
"share": "Share",
"empty": "No used cars",
"loadFailed": "Failed to load",
"pdfDownloadFailed": "Failed to download PDF",
"sourcePlace": "Source Place",
"bodyColor": "Body Color",
"plateTime": "Plate Time",
"mileage": "Mileage",
"createdAt": "Created At",
"productName": "Product Name",
"vin": "VIN",
"price": "Price",
"displacement": "Displacement",
"power": "Power",
"conditionDesc": "Condition Description",
"productImages": "Product Images",
"attachments": "Attachments",
"create": "Add Used Car",
"edit": "Edit",
"delete": "Delete",
"detail": "Detail",
"submit": "Submit",
"cancel": "Cancel",
"confirm": "Confirm",
"confirmDelete": "Confirm to delete this used car?",
"confirmMarkSold": "Confirm to mark as sold?",
"deleteSuccess": "Deleted successfully",
"updateSuccess": "Updated successfully",
"createSuccess": "Created successfully",
"setPrice": "Set Price",
"tradeTerm": "Trade Term",
"selectTradeTerm": "Please select trade term",
"enterPrice": "Enter price",
"currency": "Currency",
"language": "Language",
"pdfDownloadSuccess": "PDF downloaded successfully",
"linkCopied": "Link copied",
"notFound": "Vehicle not found",
"pageTitle": "Add Used Car",
"bind": "Bind",
"bindProduct": "Bind Product",
"bindDialog": "Select Product to Bind",
"bindSearchPlaceholder": "Search product name",
"bindLoading": "Loading…",
"bindEmpty": "No matching products found",
"bindConfirm": "Confirm Bind",
"bindClear": "Clear",
"bindBound": "Bound: ",
"bindSuccess": "Bind successful",
"bindCleared": "Bind cleared",
"enterProductName": "Enter vehicle brand and series to find model",
"enterProductNameFirst": "Please enter product name first",
"bindFetchFailed": "Failed to fetch products, please try again later",
"bindExample": "Ex: BYD Seal 07 DM",
"enterSourcePlace": "Enter source place",
"selectPlateTime": "Select time",
"enterDisplacement": "Enter displacement",
"enterPower": "Enter power",
"enterMileage": "Enter mileage",
"enterBodyColor": "Enter body color",
"enterConditionDesc": "Enter condition description",
"uploadImages": "Upload Images",
"uploadAttachments": "Upload Attachments",
"maxImages": "Maximum 20 images",
"maxAttachments": "Maximum 20 attachments",
"uploading": "Uploading...",
"uploadFailed": "Upload failed",
"removeImage": "Remove",
"view": "View",
"requiredField": "Required",
"fillRequiredFields": "Please fill all required fields",
"confirmSubmit": "Confirm submit?",
"myPublish": "My Publish",
"myGarage": "My Garage",
"quickEdit": "Quick Edit",
"emptyMyPublish": "No published vehicles yet",
"myPage": "Mine",
"personalInfo": "Personal Info",
"bindDialog": "Select Car Model",
"brand": "Brand",
"series": "Series",
"bindSearchPlaceholder": "Search car model",
"bindSearching": "Searching...",
"bindSearchFirst": "Please enter product name to search",
"bindNoModels": "No car models found",
"bindNoResults": "No matching results",
"bindConfirm": "Confirm Bind"
}
}
}
+96 -1
View File
@@ -29,7 +29,8 @@
"status": "Estado",
"remark": "Observación",
"createTime": "Fecha de creación",
"updateTime": "Fecha de actualización"
"updateTime": "Fecha de actualización",
"clear": "Limpiar"
},
"dashboard": {
"welcome": "Bienvenido de nuevo",
@@ -161,5 +162,99 @@
"lastMonth": "Mes Pasado",
"thisYear": "Este Año",
"lastYear": "Año Pasado"
},
"usedCar": {
"title": "Coche de Segunda Mano",
"code": "Código",
"search": "Buscar",
"searchPlaceholder": "Buscar vehículo",
"status": "Estado",
"statusNormal": "Normal",
"statusArchived": "Archivado",
"statusSold": "Vendido",
"markSold": "Marcar como vendido",
"downloadPdf": "Descargar PDF",
"share": "Compartir",
"empty": "Sin coches",
"loadFailed": "Error al cargar",
"pdfDownloadFailed": "Error al descargar PDF",
"sourcePlace": "Lugar de origen",
"bodyColor": "Color de carrocería",
"plateTime": "Fecha de matriculación",
"mileage": "Kilometraje",
"createdAt": "Creado en",
"productName": "Nombre del producto",
"vin": "VIN",
"price": "Precio",
"displacement": "Cilindrada",
"power": "Potencia",
"conditionDesc": "Descripción del estado",
"productImages": "Imágenes del producto",
"attachments": "Adjuntos",
"create": "Añadir coche",
"edit": "Editar",
"delete": "Eliminar",
"detail": "Detalle",
"submit": "Enviar",
"cancel": "Cancelar",
"confirm": "Confirmar",
"confirmDelete": "¿Confirmar eliminar?",
"confirmMarkSold": "¿Marcar como vendido?",
"deleteSuccess": "Eliminado",
"updateSuccess": "Actualizado",
"createSuccess": "Creado",
"setPrice": "Establecer precio",
"tradeTerm": "Término comercial",
"selectTradeTerm": "Seleccione término comercial",
"enterPrice": "Ingrese el precio",
"currency": "Moneda",
"language": "Idioma",
"pdfDownloadSuccess": "PDF descargado con éxito",
"linkCopied": "Enlace copiado",
"notFound": "Vehículo no encontrado",
"pageTitle": "Añadir Coche Usado",
"bind": "Vincular",
"bindProduct": "Vincular Producto",
"bindDialog": "Seleccionar Modelo",
"brand": "Marca",
"series": "Serie",
"bindSearchPlaceholder": "Buscar modelo",
"bindSearching": "Buscando...",
"bindSearchFirst": "Primero ingrese el nombre del producto",
"bindNoModels": "No se encontraron modelos",
"bindNoResults": "Sin resultados",
"bindConfirm": "Confirmar Vinculación",
"bindClear": "Limpiar",
"bindBound": "Vinculado: ",
"bindSuccess": "Vinculación exitosa",
"bindCleared": "Vinculación borrada",
"enterProductName": "Ingrese la marca y serie del vehículo",
"enterProductNameFirst": "Primero ingrese el nombre del producto",
"bindFetchFailed": "Error al obtener productos, inténtelo más tarde",
"bindExample": "Ej: BYD Seal 07 DM",
"enterSourcePlace": "Ingrese el lugar de origen",
"selectPlateTime": "Seleccionar tiempo",
"enterDisplacement": "Ingrese el desplazamiento",
"enterPower": "Ingrese la potencia",
"enterMileage": "Ingrese el kilometraje",
"enterBodyColor": "Ingrese el color de la carrocería",
"enterConditionDesc": "Ingrese la descripción del estado",
"uploadImages": "Subir Imágenes",
"uploadAttachments": "Subir Adjuntos",
"maxImages": "Máximo 20 imágenes",
"maxAttachments": "Máximo 20 adjuntos",
"uploading": "Subiendo...",
"uploadFailed": "Error al subir",
"removeImage": "Eliminar",
"view": "Ver",
"requiredField": "Requerido",
"fillRequiredFields": "Complete todos los campos requeridos",
"confirmSubmit": "¿Confirmar envío?",
"myPublish": "Mis Publicaciones",
"myGarage": "Mi Garaje",
"quickEdit": "Edición Rápida",
"emptyMyPublish": "Aún no has publicado vehículos",
"myPage": "Mío",
"personalInfo": "Información personal"
}
}
+96 -1
View File
@@ -29,7 +29,8 @@
"status": "Statut",
"remark": "Remarque",
"createTime": "Date de création",
"updateTime": "Date de mise à jour"
"updateTime": "Date de mise à jour",
"clear": "Effacer"
},
"dashboard": {
"welcome": "Bienvenue",
@@ -161,5 +162,99 @@
"lastMonth": "Mois Dernier",
"thisYear": "Cette Année",
"lastYear": "Année Dernière"
},
"usedCar": {
"title": "Voiture d'Occasion",
"code": "Code",
"search": "Rechercher",
"searchPlaceholder": "Rechercher véhicule",
"status": "Statut",
"statusNormal": "Normal",
"statusArchived": "Archivé",
"statusSold": "Vendu",
"markSold": "Marquer comme vendu",
"downloadPdf": "Télécharger PDF",
"share": "Partager",
"empty": "Aucune voiture",
"loadFailed": "Échec du chargement",
"pdfDownloadFailed": "Échec du téléchargement PDF",
"sourcePlace": "Lieu d'origine",
"bodyColor": "Couleur de la carrosserie",
"plateTime": "Date d'immatriculation",
"mileage": "Kilométrage",
"createdAt": "Créé le",
"productName": "Nom du produit",
"vin": "VIN",
"price": "Prix",
"displacement": "Cylindrée",
"power": "Puissance",
"conditionDesc": "Description de l'état",
"productImages": "Images du produit",
"attachments": "Pièces jointes",
"create": "Ajouter voiture",
"edit": "Modifier",
"delete": "Supprimer",
"detail": "Détail",
"submit": "Soumettre",
"cancel": "Annuler",
"confirm": "Confirmer",
"confirmDelete": "Confirmer la suppression?",
"confirmMarkSold": "Marquer comme vendu?",
"deleteSuccess": "Supprimé",
"updateSuccess": "Mis à jour",
"createSuccess": "Créé",
"setPrice": "Définir le prix",
"tradeTerm": "Terme commercial",
"selectTradeTerm": "Sélectionnez le terme commercial",
"enterPrice": "Entrez le prix",
"currency": "Devise",
"language": "Langue",
"pdfDownloadSuccess": "PDF téléchargé avec succès",
"linkCopied": "Lien copié",
"notFound": "Véhicule introuvable",
"pageTitle": "Ajouter une Voiture d'occasion",
"bind": "Lier",
"bindProduct": "Lier le Produit",
"bindDialog": "Sélectionner le Modèle",
"brand": "Marque",
"series": "Série",
"bindSearchPlaceholder": "Rechercher modèle",
"bindSearching": "Recherche...",
"bindSearchFirst": "Veuillez d'abord entrer le nom du produit",
"bindNoModels": "Aucun modèle trouvé",
"bindNoResults": "Aucun résultat",
"bindConfirm": "Confirmer la Liaison",
"bindClear": "Effacer",
"bindBound": "Lié : ",
"bindSuccess": "Liaison réussie",
"bindCleared": "Liaison effacée",
"enterProductName": "Entrez la marque et la série du véhicule",
"enterProductNameFirst": "Veuillez d'abord entrer le nom du produit",
"bindFetchFailed": "Échec de la récupération des produits, réessayez plus tard",
"bindExample": "Ex: BYD Seal 07 DM",
"enterSourcePlace": "Entrez le lieu d'origine",
"selectPlateTime": "Sélectionner l'heure",
"enterDisplacement": "Entrez la cylindrée",
"enterPower": "Entrez la puissance",
"enterMileage": "Entrez le kilométrage",
"enterBodyColor": "Entrez la couleur de la carrosserie",
"enterConditionDesc": "Entrez la description de l'état",
"uploadImages": "Télécharger des Images",
"uploadAttachments": "Télécharger des Pièces Jointes",
"maxImages": "Maximum 20 images",
"maxAttachments": "Maximum 20 pièces jointes",
"uploading": "Téléchargement...",
"uploadFailed": "Échec du téléchargement",
"removeImage": "Supprimer",
"view": "Voir",
"requiredField": "Obligatoire",
"fillRequiredFields": "Veuillez remplir tous les champs obligatoires",
"confirmSubmit": "Confirmer l'envoi ?",
"myPublish": "Mes Publications",
"myGarage": "Mon Garage",
"quickEdit": "Modification Rapide",
"emptyMyPublish": "Vous n'avez pas encore publié de véhicules",
"myPage": "Mon",
"personalInfo": "Informations personnelles"
}
}
+96 -1
View File
@@ -29,7 +29,8 @@
"status": "Статус",
"remark": "Примечание",
"createTime": "Время создания",
"updateTime": "Время обновления"
"updateTime": "Время обновления",
"clear": "Очистить"
},
"dashboard": {
"welcome": "Добро пожаловать",
@@ -161,5 +162,99 @@
"lastMonth": "В прошлом месяце",
"thisYear": "В этом году",
"lastYear": "В прошлом году"
},
"usedCar": {
"title": "Подержанные автомобили",
"code": "Код",
"search": "Поиск",
"searchPlaceholder": "Поиск автомобиля",
"status": "Статус",
"statusNormal": "Норма",
"statusArchived": "Архив",
"statusSold": "Продан",
"markSold": "Пометить как проданный",
"downloadPdf": "Скачать PDF",
"share": "Поделиться",
"empty": "Нет подержанных автомобилей",
"loadFailed": "Ошибка загрузки",
"pdfDownloadFailed": "Ошибка загрузки PDF",
"sourcePlace": "Место происхождения",
"bodyColor": "Цвет кузова",
"plateTime": "Дата регистрации",
"mileage": "Пробег",
"createdAt": "Дата создания",
"productName": "Название продукта",
"vin": "VIN",
"price": "Цена",
"displacement": "Объем двигателя",
"power": "Мощность",
"conditionDesc": "Описание состояния",
"productImages": "Фотографии продукта",
"attachments": "Вложения",
"create": "Добавить автомобиль",
"edit": "Редактировать",
"delete": "Удалить",
"detail": "Детали",
"submit": "Отправить",
"cancel": "Отмена",
"confirm": "Подтвердить",
"confirmDelete": "Подтвердить удаление?",
"confirmMarkSold": "Пометить как проданный?",
"deleteSuccess": "Удалено",
"updateSuccess": "Обновлено",
"createSuccess": "Создано",
"setPrice": "Установить цену",
"tradeTerm": "Условия торговли",
"selectTradeTerm": "Выберите условия торговли",
"enterPrice": "Введите цену",
"currency": "Валюта",
"language": "Язык",
"pdfDownloadSuccess": "PDF успешно загружен",
"linkCopied": "Ссылка скопирована",
"notFound": "Автомобиль не найден",
"pageTitle": "Добавить подержанный автомобиль",
"bind": "Привязать",
"bindProduct": "Привязать продукт",
"bindDialog": "Выберите модель",
"brand": "Бренд",
"series": "Серия",
"bindSearchPlaceholder": "Поиск модели",
"bindSearching": "Поиск...",
"bindSearchFirst": "Сначала введите название продукта",
"bindNoModels": "Модели не найдены",
"bindNoResults": "Нет результатов",
"bindConfirm": "Подтвердить",
"bindClear": "Очистить",
"bindBound": "Привязано: ",
"bindSuccess": "Привязка успешна",
"bindCleared": "Привязка очищена",
"enterProductName": "Введите бренд и серию автомобиля",
"enterProductNameFirst": "Сначала введите название продукта",
"bindFetchFailed": "Не удалось получить продукты, попробуйте позже",
"bindExample": "Пример: BYD Seal 07 DM",
"enterSourcePlace": "Введите место происхождения",
"selectPlateTime": "Выберите время",
"enterDisplacement": "Введите объем двигателя",
"enterPower": "Введите мощность",
"enterMileage": "Введите пробег",
"enterBodyColor": "Введите цвет кузова",
"enterConditionDesc": "Введите описание состояния",
"uploadImages": "Загрузить изображения",
"uploadAttachments": "Загрузить вложения",
"maxImages": "Максимум 20 изображений",
"maxAttachments": "Максимум 20 вложений",
"uploading": "Загрузка...",
"uploadFailed": "Загрузка не удалась",
"removeImage": "Удалить",
"view": "Просмотр",
"requiredField": "Обязательно",
"fillRequiredFields": "Заполните все обязательные поля",
"confirmSubmit": "Подтвердить отправку?",
"myPublish": "Мои объявления",
"myGarage": "Мой гараж",
"quickEdit": "Быстрое редактирование",
"emptyMyPublish": "У вас пока нет опубликованных автомобилей",
"myPage": "Мой",
"personalInfo": "Личная информация"
}
}
+96 -1
View File
@@ -29,7 +29,8 @@
"status": "状态",
"remark": "备注",
"createTime": "创建时间",
"updateTime": "更新时间"
"updateTime": "更新时间",
"clear": "清除"
},
"dashboard": {
"welcome": "欢迎回来",
@@ -186,5 +187,99 @@
"lastMonth": "上月",
"thisYear": "今年",
"lastYear": "去年"
},
"usedCar": {
"title": "二手车",
"code": "编号",
"search": "搜索",
"searchPlaceholder": "搜索车辆名称",
"status": "状态",
"statusNormal": "正常",
"statusArchived": "归档",
"statusSold": "已售出",
"markSold": "标记已售出",
"downloadPdf": "下载PDF",
"share": "分享",
"empty": "暂无二手车",
"loadFailed": "加载失败",
"pdfDownloadFailed": "PDF下载失败",
"sourcePlace": "车源地",
"bodyColor": "车身颜色",
"plateTime": "上牌时间",
"mileage": "里程",
"createdAt": "创建时间",
"productName": "产品名称",
"vin": "车架号",
"price": "车价",
"displacement": "排量",
"power": "马力",
"conditionDesc": "车况介绍",
"productImages": "产品图",
"attachments": "附件",
"create": "添加二手车",
"edit": "编辑",
"delete": "删除",
"detail": "详情",
"submit": "提交",
"cancel": "取消",
"confirm": "确认",
"confirmDelete": "确认删除此二手车?",
"confirmMarkSold": "确认标记为已售出?",
"deleteSuccess": "删除成功",
"updateSuccess": "更新成功",
"createSuccess": "创建成功",
"setPrice": "设置价格",
"tradeTerm": "贸易条款",
"selectTradeTerm": "请选择贸易条款",
"enterPrice": "请输入价格",
"currency": "币种",
"language": "语言",
"pdfDownloadSuccess": "PDF下载成功",
"linkCopied": "链接已复制",
"notFound": "未找到该车辆",
"pageTitle": "二手车录入",
"bind": "绑定",
"bindProduct": "绑定产品",
"bindDialog": "选择车型",
"brand": "品牌",
"series": "车系",
"bindSearchPlaceholder": "搜索车型",
"bindSearching": "搜索中...",
"bindSearchFirst": "请先输入产品名称搜索",
"bindNoModels": "未找到相关车型",
"bindNoResults": "未找到匹配结果",
"bindConfirm": "确认绑定",
"bindClear": "清除",
"bindBound": "已绑定:",
"bindSuccess": "绑定成功",
"bindCleared": "已清除绑定",
"enterProductName": "请输入车辆的品牌 系列查找车型",
"enterProductNameFirst": "请先输入产品名称",
"bindFetchFailed": "拉取产品失败,请稍后重试",
"bindExample": "例:比亚迪海狮07DM",
"enterSourcePlace": "请输入车源地",
"selectPlateTime": "请选择时间",
"enterDisplacement": "请输入排量",
"enterPower": "请输入马力",
"enterMileage": "请输入里程",
"enterBodyColor": "请输入车身颜色",
"enterConditionDesc": "请输入车况介绍",
"uploadImages": "上传产品图",
"uploadAttachments": "上传附件",
"maxImages": "最多上传20张图片",
"maxAttachments": "最多上传20个附件",
"uploading": "上传中...",
"uploadFailed": "上传失败",
"removeImage": "删除",
"view": "查看",
"requiredField": "必填项",
"fillRequiredFields": "请填写所有必填项",
"confirmSubmit": "确认提交?",
"myPublish": "我的发布",
"myGarage": "我的车库",
"quickEdit": "快捷编辑",
"emptyMyPublish": "暂无发布车辆",
"myPage": "我的",
"personalInfo": "个人信息"
}
}
+3
View File
@@ -35,12 +35,15 @@ const quickActions: QuickAction[] = [
{ id: '1', icon: '✈️', label: '出差申请', path: '/business-trip/apply', color: '#1677ff', description: '提交出差申请' },
{ id: '2', icon: '📝', label: '采购申请', path: '/purchase/apply', color: '#52c41a', description: '物品采购审批', disabled: true },
{ id: '3', icon: '📦', label: '备货录入', path: '/stock/entry', color: '#faad14', description: '库存备货录入', disabled: true },
{ id: '4', icon: '🚗', label: '发布车辆', path: '/used-car/create', color: '#722ed1', description: '发布二手车' },
{ id: '5', icon: '📋', label: '我的发布', path: '/used-car/my-list', color: '#722ed1', description: '我发布的车辆' },
]
const allApps: QuickAction[] = [
{ id: '1', icon: '✍️', label: '审批', path: '/approval', color: '#52c41a', description: '待办事项审批' },
{ id: '2', icon: '📊', label: '数据统计', path: '/reports', color: '#1890ff', description: '业务数据统计', disabled: true },
{ id: '3', icon: '⚙️', label: '系统设置', path: '/settings', color: '#8c8c8c', description: '系统偏好设置' },
{ id: '4', icon: '🚗', label: '二手车', path: '/used-car', color: '#722ed1', description: '二手车管理' },
]
export default function Dashboard() {
+12
View File
@@ -17,6 +17,18 @@ export default function Settings() {
const { t } = useTranslation()
const settingGroups = [
{
title: t('profile.myInfo'),
items: [
{
id: 'personal-info',
icon: '👤',
label: t('usedCar.personalInfo'),
description: t('usedCar.personalInfo'),
path: '/used-car/profile',
},
],
},
{
title: t('profile.security'),
items: [
@@ -0,0 +1,718 @@
.createPage {
min-height: 100vh;
background: #f4f6f8;
padding-bottom: 100px;
}
.createContent {
padding: 6px;
}
/* 表单卡片 */
.formCard {
background: #fff;
border-radius: 14px;
overflow: hidden;
margin-bottom: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
/* 表单单元格 */
.formCell {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
min-height: 52px;
}
.formCell:last-child {
border-bottom: none;
}
.formCellVertical {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.formLabel {
font-size: 15px;
font-weight: 500;
color: #333;
min-width: 80px;
flex-shrink: 0;
}
.required {
color: #ff4d4f;
margin-left: 2px;
}
.formCellVertical .formLabel {
width: 100%;
min-width: 0;
margin-bottom: 4px;
}
/* 输入框样式 */
.admInputWrapper {
flex: 1;
min-width: 0;
}
.admInputWrapper :global(.adm-input) {
--font-size: 15px;
--color-text: #333;
background: transparent;
padding: 0;
}
.admInputWrapper :global(.adm-input::placeholder) {
color: #bbb;
}
/* 产品名称 + 绑定按钮 */
.inputWithAction {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.inputWithAction .admInputWrapper {
flex: 1;
}
.bindBtn {
flex-shrink: 0;
padding: 0 16px;
height: 32px;
border-radius: 8px;
background: #2E6CF6;
color: #fff;
font-size: 13px;
font-weight: 500;
border: none;
transition: all 0.2s;
}
.bindBtn:active {
opacity: 0.8;
transform: scale(0.98);
}
/* 绑定提示 */
.bindTip {
padding: 10px 16px;
background: #F3F6FF;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
margin: 0 0px 12px;
border-radius: 10px;
}
.bindTipText {
color: #2B3A55;
}
.bindTipClear {
color: #2E6CF6;
cursor: pointer;
font-weight: 500;
}
/* 选择器触发器 */
.pickerTrigger {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 24px;
cursor: pointer;
flex: 1;
}
.pickerTrigger::after {
content: '';
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #bbb;
margin-left: 8px;
}
.pickerValue {
font-size: 15px;
color: #333;
}
.pickerPlaceholder {
font-size: 15px;
color: #bbb;
}
/* 表单区域 */
.formSection {
background: #fff;
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.formSectionLabel {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 12px;
display: block;
}
.imageUploadArea {
min-height: 80px;
}
.attachmentArea {
display: flex;
flex-direction: column;
gap: 12px;
}
.uploadBtn {
padding: 10px 20px;
border-radius: 10px;
background: #2E6CF6;
color: #fff;
font-size: 14px;
font-weight: 500;
border: none;
transition: all 0.2s;
align-self: flex-start;
}
.uploadBtn:active {
opacity: 0.8;
transform: scale(0.98);
}
.uploadBtn:disabled {
opacity: 0.5;
background: #ccc;
cursor: not-allowed;
}
.attachmentList {
display: flex;
flex-direction: column;
gap: 8px;
}
.attachmentItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f9f9f9;
border-radius: 10px;
border: 1px solid #e8e8e8;
}
.attachmentName {
font-size: 14px;
color: #333;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachmentDelete {
color: #ff4d4f;
font-size: 13px;
cursor: pointer;
margin-left: 12px;
flex-shrink: 0;
}
.attachmentDelete:active {
opacity: 0.7;
}
.attachmentHint {
padding: 24px;
text-align: center;
color: #999;
font-size: 14px;
background: #f9f9f9;
border-radius: 10px;
border: 1px dashed #ddd;
}
/* 提交栏 */
.submitBar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #f0f0f0;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
z-index: 100;
}
.submitBtn {
height: 48px;
border-radius: 24px;
background: #3F435B;
color: #fff;
font-size: 16px;
font-weight: 600;
border: none;
box-shadow: 0 4px 12px rgba(63, 67, 91, 0.3);
transition: all 0.2s;
}
.submitBtn:active {
opacity: 0.9;
transform: scale(0.98);
}
/* ========== 绑定弹窗样式 ========== */
/* 遮罩层 */
:global(.adm-modal-mask) {
background: rgba(0, 0, 0, 0.45) !important;
}
/* 弹窗容器 */
:global(.adm-modal-body) {
border-radius: 20px !important;
overflow: hidden !important;
max-width: 420px !important;
/* width: 92% !important; */
max-height: 80vh !important;
}
:global(.adm-modal-content) {
padding: 0 !important;
border-radius: 20px !important;
overflow: hidden !important;
max-height: 80vh !important;
display: flex !important;
flex-direction: column !important;
}
/* 弹窗内容区域 */
.bindDialogContent {
display: flex;
flex-direction: column;
max-height: 80vh;
overflow: hidden;
}
/* 弹窗头部 */
.bindDialogHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.bindDialogTitle {
font-size: 17px;
font-weight: 600;
color: #111827;
letter-spacing: 0.3px;
}
.bindDialogClose {
font-size: 28px;
color: #999;
cursor: pointer;
line-height: 1;
padding: 4px 8px;
margin: -4px -8px;
border-radius: 50%;
transition: all 0.2s;
}
.bindDialogClose:hover {
color: #666;
background: #f5f5f5;
}
.bindDialogClose:active {
transform: scale(0.9);
}
/* 品牌/车系信息区 */
.bindBrandInfo {
padding: 14px 20px;
background: linear-gradient(135deg, #F8FAFC 0%, #F3F6FF 100%);
border-bottom: 1px solid #e8ecf0;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.bindBrandInfoLabel {
font-size: 13px;
color: #64748b;
font-weight: 500;
}
.bindBrandInfoValue {
font-size: 15px;
color: #0f172a;
font-weight: 600;
}
.bindBrandInfoSeparator {
color: #cbd5e1;
font-size: 14px;
}
/* 搜索框区域 */
.bindSearchArea {
padding: 12px 16px;
background: #fff;
flex-shrink: 0;
}
.bindSearch {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #F8FAFC;
border-radius: 14px;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.bindSearch:focus-within {
background: #fff;
border-color: #2E6CF6;
box-shadow: 0 0 0 3px rgba(46, 108, 246, 0.1);
}
.bindSearch :global(.adm-input) {
flex: 1;
background: transparent;
--font-size: 15px;
--color-text: #333;
}
.bindSearch :global(.adm-input::placeholder) {
color: #94a3b8;
}
.bindSearchClear {
color: #2E6CF6;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
font-weight: 500;
padding: 6px 10px;
border-radius: 8px;
transition: all 0.2s;
}
.bindSearchClear:active {
background: #eff6ff;
}
/* 车型列表区域 */
.bindList {
flex: 1;
overflow-y: auto;
padding: 8px 16px;
background: #f8fafc;
min-height: 200px;
max-height: 40vh;
}
.bindList::-webkit-scrollbar {
width: 4px;
}
.bindList::-webkit-scrollbar-track {
background: transparent;
}
.bindList::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
/* 加载和空状态 */
.bindLoading,
.bindEmpty {
padding: 60px 20px;
text-align: center;
color: #94a3b8;
font-size: 14px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.bindLoading::before {
content: '';
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #2E6CF6;
border-radius: 50%;
animation: bindSpin 0.8s linear infinite;
}
@keyframes bindSpin {
to {
transform: rotate(360deg);
}
}
/* 车型列表项 */
.bindItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
margin-bottom: 8px;
border-radius: 14px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
background: #fff;
border: 2px solid transparent;
position: relative;
overflow: hidden;
}
.bindItem::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(46, 108, 246, 0.05) 0%, rgba(46, 108, 246, 0.02) 100%);
opacity: 0;
transition: opacity 0.25s;
}
.bindItem:active {
transform: scale(0.98);
}
.bindItem.active {
background: linear-gradient(135deg, #F0F5FF 0%, #E8F0FF 100%);
border-color: #2E6CF6;
box-shadow: 0 4px 12px rgba(46, 108, 246, 0.15);
}
.bindItem.active::before {
opacity: 1;
}
.bindItemMain {
flex: 1;
padding-right: 16px;
min-width: 0;
position: relative;
z-index: 1;
}
.bindItemTitle {
font-size: 15px;
font-weight: 600;
color: #1e293b;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.2px;
}
.bindItem.active .bindItemTitle {
color: #2E6CF6;
}
.bindItemSub {
font-size: 13px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
}
.bindItemSub::before {
content: '¥';
font-size: 11px;
color: #94a3b8;
}
.bindItem.active .bindItemSub {
color: #475569;
}
/* 单选按钮样式 */
.bindItemRadio {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1;
background: #fff;
}
.bindItem.active .bindItemRadio {
border-color: #2E6CF6;
background: #2E6CF6;
box-shadow: 0 2px 8px rgba(46, 108, 246, 0.3);
}
.bindItemRadioDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
transform: scale(0);
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.bindItem.active .bindItemRadioDot {
transform: scale(1);
}
/* 底部按钮区 - 固定在底部 */
.bindDialogFooter {
display: flex;
gap: 12px;
padding: 16px 20px;
padding-bottom: max(16px, env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
position: sticky;
bottom: 0;
}
.bindCancelBtn,
.bindConfirmBtn {
flex: 1;
height: 48px;
border-radius: 14px;
font-size: 16px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 0.3px;
}
.bindCancelBtn {
background: #f5f6f8;
color: #475569;
}
.bindCancelBtn:active {
background: #e8eaed;
transform: scale(0.97);
}
.bindConfirmBtn {
background: linear-gradient(135deg, #2E6CF6 0%, #2563eb 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(46, 108, 246, 0.25);
}
.bindConfirmBtn:active {
transform: scale(0.97);
box-shadow: 0 2px 6px rgba(46, 108, 246, 0.25);
}
.bindConfirmBtn:disabled {
opacity: 0.5;
background: #e2e8f0;
box-shadow: none;
cursor: not-allowed;
}
.bindConfirmBtn:disabled:active {
transform: none;
}
/* 图片上传区域 */
.admImageUploader {
--cell-size: 80px;
}
.admImageUploader :global(.adm-image-uploader-cell-mask) {
border-radius: 10px;
}
/* TextArea 样式 */
.admTextAreaWrapper :global(.adm-text-area) {
--font-size: 15px;
--color-text: #333;
background: #f9f9f9;
border-radius: 10px;
padding: 12px;
}
/* 兼容旧的 checkDot 类名 */
.checkDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
transform: scale(0);
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 兼容旧的 bindItemCheck 类名 */
.bindItemCheck {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1;
background: #fff;
}
.bindItem.active .bindItemCheck {
border-color: #2E6CF6;
background: #2E6CF6;
box-shadow: 0 2px 8px rgba(46, 108, 246, 0.3);
}
.bindItem.active .checkDot {
transform: scale(1);
}
+594
View File
@@ -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;
}
+584
View File
@@ -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
}
+741
View File
@@ -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
+594
View File
@@ -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;
}
+185
View File
@@ -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
+131
View File
@@ -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;
}
+139
View File
@@ -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>
)
}
+187
View File
@@ -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
+14 -1
View File
@@ -18,6 +18,13 @@ import SimpleLogin from '@/pages/login-simple'
import DebugFeishu from '@/pages/debug-feishu'
import FeishuEnvTest from '@/pages/feishu-env-test'
import UsedCarList from '@/pages/used-car/list'
import UsedCarDetail from '@/pages/used-car/detail'
import UsedCarCreate from '@/pages/used-car/create'
import UsedCarEdit from '@/pages/used-car/edit'
import UsedCarMine from '@/pages/used-car/mine'
import UsedCarMyList from '@/pages/used-car/my-list'
const router = createBrowserRouter([
{
path: '/',
@@ -36,9 +43,15 @@ const router = createBrowserRouter([
{ path: 'business-trip/:id', element: <BusinessTripDetail /> },
{ path: 'approval', element: <ApprovalList /> },
{ path: 'approval/:id', element: <ApprovalDetail /> },
{ path: 'used-car', element: <UsedCarList /> },
{ path: 'used-car/mine', element: <UsedCarMine /> },
{ path: 'used-car/my-list', element: <UsedCarMyList /> },
{ path: 'used-car/create', element: <UsedCarCreate /> },
{ path: 'used-car/edit/:id', element: <UsedCarEdit /> },
{ path: 'used-car/detail/:id', element: <UsedCarDetail /> },
],
},
// 详情页移到顶层,支持未登录访问(页面自己处理登录检查)
// 详情页移到顶层,支持未登录访问(页面自己处理登录检查)
{ path: '/approval/detail/:id', element: <ApprovalDetail /> },
{ path: '/business-trip/detail/:id', element: <BusinessTripDetail /> },
{ path: '/login', element: <Login /> },
+136 -1
View File
@@ -261,7 +261,8 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => {
const { data } = response
if (data.code === 0) {
// 后端成功返回 code === 200
if (data.code === 200 || data.code === 0) {
return data.data
}
return Promise.reject(new Error(data.message || '请求失败'))
@@ -401,4 +402,138 @@ export const approvalApi = {
cancel: (id: number) => api.post(`/approval/cancel/${id}`),
}
// ========== 二手车 API ==========
export const usedCarApi = {
// 获取二手车列表
getList: (params: {
type?: 'self' | 'all'
page?: number
page_size?: number
keyword?: string
status?: number
}) => api.get<any, any>('/usedcar/list', { params }),
// 获取二手车详情
getDetail: (id: number) => api.get<any, any>('/usedcar/detail', { params: { id } }),
// 创建二手车
create: (data: any) => api.post<any, { id: number }>('/usedcar/create', data),
// 更新二手车
update: (data: any) => api.post<any, any>('/usedcar/update', data),
// 删除二手车
delete: (id: number) => api.post<any, any>('/usedcar/delete', { id }),
// 更新状态
updateStatus: (id: number, status: number) =>
api.post<any, any>('/usedcar/status', { id, status }),
// 爬取URL
crawlByUrl: (url: string) => api.post<any, any>('/usedcar/crawl/url', { url }),
// 爬取移动端URL
crawlMobile: (url: string) => api.post<any, any>('/usedcar/crawl/mobile', { url }),
// 关键词搜索车型
crawlKeyword: (keyword: string) => api.post<any, {
brand: string
series: string
series_id: string
models: Array<{
id: number
name: string
url: string
msrp: number
}>
}>('/usedcar/crawl/keyword', { keyword }),
// 生成PDF
generatePdf: (params: {
id: number
trade_term?: string
price?: number
currency?: string
lang?: string
}) => api.post<any, { pdf_url: string; file_name: string }>('/usedcar/pdf/generate', params),
// 下载PDF
downloadPdf: (id: number) => api.get<any, any>('/usedcar/pdf/download', { params: { id } }),
// 获取PDF URL
getPdfUrl: (id: number) => api.get<any, { pdf_url: string; file_name: string }>('/usedcar/pdf/url', { params: { id } }),
// 预览PDF
previewPdf: (id: number) => api.get<any, any>('/usedcar/pdf/preview', { params: { id } }),
}
// ========== 文件上传 API ==========
export const uploadApi = {
// 上传二手车图片
uploadUsedCarImage: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return api.post<any, any>('/upload/usedcar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
}).then((res) => {
// 后端返回 file_url(相对路径)和 full_url(完整URL)
// 使用 file_url 因为它在开发和生产环境都能正确访问
console.log('上传响应:', res)
return {
...res,
url: res.file_url || res.full_url || res.url
}
})
},
// 批量上传图片
batchUploadImages: (files: File[]) => {
const formData = new FormData()
files.forEach(file => {
formData.append('files[]', file)
})
return api.post<any, any>('/upload/batch', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
},
// 通用图片上传
uploadImage: (file: File, category = 'general') => {
const formData = new FormData()
formData.append('file', file)
formData.append('category', category)
return api.post<any, any>('/upload/image', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
},
// 删除文件
deleteFile: (fileId: number) => api.post<any, any>('/upload/delete', { id: fileId }),
// 通用文件上传(支持附件,如 PDF、Word 等)
uploadFile: (file: File, partition = 'document', category = 'general') => {
const formData = new FormData()
formData.append('file', file)
formData.append('partition', partition)
formData.append('category', category)
return api.post<any, any>('/upload/file', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
}).then((res) => {
return {
...res,
url: res.file_url || res.full_url || res.url
}
})
},
}
export default api
+4
View File
@@ -26,6 +26,10 @@ export default defineConfig({
target: 'http://127.0.0.1:8787',
changeOrigin: true,
},
'/uploads': {
target: 'http://127.0.0.1:8787',
changeOrigin: true,
},
},
},
build: {