feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、 SKU 目录等模块,以及前端相应页面与样式改版。 后端 - 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定 - 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志 - 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等) - 新增合同解析、合同文件保留策略、客户归属与访问控制 - 新增 SKU 目录、组织品牌、新客开发、系统日志等路由 前端 - 新增登录页与用户 / 权限组 / 系统日志管理页 - 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心 - 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件 - 新增英文大写金额、未保存改动拦截、抽屉与骨架屏 - 新增 stage1 样式层;产品、供应商、订单等页面改版 - EmbeddedSku 页面并入现有产品体系后删除 开源准备 - 新增 AGPL-3.0 LICENSE - README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、 补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0 - 移除历史遗留的空文件 _index_backup.txt - 文档中的项目路径更新为 happy-export
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
CleanupIcon, DownloadIcon, RefreshIcon, CheckIcon, CloseIcon, PencilIcon, SparkIcon, ContractIcon
|
||||
} from '../components/Icons'
|
||||
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
|
||||
|
||||
const JOB_STATUS = {
|
||||
queued: { label: '排队中', tone: 'mute' },
|
||||
parsing: { label: '解析中', tone: 'warn' },
|
||||
pending_review: { label: '待复核', tone: 'warn' },
|
||||
reviewed: { label: '已复核', tone: 'ok' },
|
||||
parse_failed: { label: '解析失败', tone: 'danger' }
|
||||
}
|
||||
|
||||
const ITEM_STATUS = {
|
||||
pending_review: { label: '待复核', tone: 'warn' },
|
||||
approved: { label: '已通过', tone: 'ok' },
|
||||
rejected: { label: '已驳回', tone: 'danger' }
|
||||
}
|
||||
|
||||
/** 一条明细的状态显示:入库过的优先显示「已入库」——它比 approved 更具体 */
|
||||
function itemStatusMeta(item) {
|
||||
if (item.committed_at) return { label: '已入库', tone: 'ok' }
|
||||
return ITEM_STATUS[item.status] || { label: item.status, tone: 'mute' }
|
||||
}
|
||||
|
||||
function formatMoment(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (!Number.isFinite(bytes)) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** 解析结果表头认了哪几列 —— 规则引擎唯一能自证的现场,认错了就看这里 */
|
||||
function ColumnMapPanel({ raw }) {
|
||||
const map = useMemo(() => {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
const entries = Object.entries(parsed || {})
|
||||
return entries.length ? entries : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [raw])
|
||||
|
||||
if (!map) {
|
||||
return <p className="muted-note">没有认出任一列 —— 可以用「AI 解析」或逐条手工补录。</p>
|
||||
}
|
||||
return (
|
||||
<div className="col-map">
|
||||
{map.map(([field, hit]) => (
|
||||
<span className="col-map-item" key={field}>
|
||||
<b>{field}</b>
|
||||
<span className="col-map-arrow">←</span>
|
||||
<span className="col-map-head">{hit.header}</span>
|
||||
<em>{hit.score} 分</em>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单行复核编辑器:点「修正」后就地变成可编辑,避免为了改一个价格再开一层弹窗 */
|
||||
function ReviewRow({ item, selected, onToggle, onReviewed, onCommitted, thumbs }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
sku: item.sku || '',
|
||||
product_name: item.product_name || '',
|
||||
spec: item.spec || '',
|
||||
purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price),
|
||||
currency: item.currency || '',
|
||||
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
|
||||
moq: item.moq === null || item.moq === undefined ? '' : String(item.moq),
|
||||
review_note: item.review_note || ''
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
sku: item.sku || '',
|
||||
product_name: item.product_name || '',
|
||||
spec: item.spec || '',
|
||||
purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price),
|
||||
currency: item.currency || '',
|
||||
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
|
||||
moq: item.moq === null || item.moq === undefined ? '' : String(item.moq),
|
||||
review_note: item.review_note || ''
|
||||
})
|
||||
setEditing(false)
|
||||
setError('')
|
||||
}, [item])
|
||||
|
||||
const payload = status => ({
|
||||
status,
|
||||
review_note: form.review_note.trim() || null,
|
||||
sku: form.sku.trim() || null,
|
||||
product_name: form.product_name.trim() || null,
|
||||
spec: form.spec.trim() || null,
|
||||
purchase_price: form.purchase_price === '' ? null : Number(form.purchase_price),
|
||||
currency: form.currency.trim() || null,
|
||||
quantity: form.quantity === '' ? null : Number(form.quantity),
|
||||
moq: form.moq === '' ? null : Number(form.moq)
|
||||
})
|
||||
|
||||
/** 只改状态(驳回 / 放回待复核) */
|
||||
const send = async (status) => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload(status))
|
||||
setEditing(false)
|
||||
onReviewed()
|
||||
} catch (reviewError) {
|
||||
setError(reviewError?.response?.data?.error || '操作失败,请稍后重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存修正并直接入库:先把改过的字段存回队列,再走入库接口 */
|
||||
const saveAndCommit = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload('pending_review'))
|
||||
await axios.post('/api/contracts/import-items/commit', { ids: [item.id] })
|
||||
setEditing(false)
|
||||
onCommitted()
|
||||
} catch (commitError) {
|
||||
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 不改任何字段,直接把这一条入库 */
|
||||
const commitOne = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await axios.post('/api/contracts/import-items/commit', { ids: [item.id] })
|
||||
onCommitted()
|
||||
} catch (commitError) {
|
||||
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusMeta = itemStatusMeta(item)
|
||||
const committed = Boolean(item.committed_at)
|
||||
const renamed = committed && item.product_sku && item.sku && item.product_sku !== item.sku
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<tr className="import-row is-editing">
|
||||
<td />
|
||||
<td className="mono">{item.filename}{item.row_no ? `#${item.row_no}` : ''}</td>
|
||||
<td>
|
||||
<input className="cell-input" type="text" value={form.sku} placeholder="货号"
|
||||
onChange={event => setForm({ ...form, sku: event.target.value })} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="cell-input" type="text" value={form.product_name} placeholder="产品名称"
|
||||
onChange={event => setForm({ ...form, product_name: event.target.value })} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="cell-input" type="text" value={form.spec} placeholder="规格"
|
||||
onChange={event => setForm({ ...form, spec: event.target.value })} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="cell-input" type="number" step="0.01" value={form.purchase_price} placeholder="采购价"
|
||||
onChange={event => setForm({ ...form, purchase_price: event.target.value })} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="cell-input" type="text" value={form.currency} placeholder="币种"
|
||||
onChange={event => setForm({ ...form, currency: event.target.value })} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="cell-input" type="number" step="1" value={form.quantity} placeholder="数量"
|
||||
onChange={event => setForm({ ...form, quantity: event.target.value })} />
|
||||
</td>
|
||||
<td>
|
||||
<input className="cell-input" type="number" step="1" value={form.moq} placeholder="起订量"
|
||||
onChange={event => setForm({ ...form, moq: event.target.value })} />
|
||||
</td>
|
||||
<td colSpan={2}>
|
||||
<input className="cell-input" type="text" value={form.review_note} placeholder="复核备注"
|
||||
onChange={event => setForm({ ...form, review_note: event.target.value })} />
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setEditing(false)} disabled={saving}>取消</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={saveAndCommit} disabled={saving}>
|
||||
{saving ? '入库中…' : '保存并入库'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={`import-row${item.status === 'rejected' ? ' is-rejected' : ''}${selected ? ' is-picked' : ''}`}>
|
||||
<td className="col-pick">
|
||||
<input
|
||||
className="pick-box" type="checkbox" checked={selected}
|
||||
onChange={() => onToggle(item.id)}
|
||||
aria-label={`选择 ${item.sku || item.filename + '#' + item.row_no}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="mono">
|
||||
<span className="cell-file" title={`${item.filename}${item.row_no ? ` #${item.row_no}` : ''}`}>
|
||||
{item.filename}{item.row_no ? `#${item.row_no}` : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono bold">
|
||||
{item.sku ? (
|
||||
<span className="sku-cell">
|
||||
<SkuThumb sku={item.sku} filename={thumbs[item.sku]} size={30} />
|
||||
{item.sku}
|
||||
{renamed && <em className="sku-renamed" title="货号已存在,入库时自动落成副本">→ {item.product_sku}</em>}
|
||||
</span>
|
||||
) : <span className="muted-note">未识别</span>}
|
||||
</td>
|
||||
<td>{item.product_name || <span className="muted-note">未识别</span>}</td>
|
||||
<td>{item.spec || <span className="muted-note">—</span>}</td>
|
||||
<td className="num">
|
||||
{item.purchase_price === null || item.purchase_price === undefined
|
||||
? <span className="muted-note">—</span>
|
||||
: item.purchase_price}
|
||||
</td>
|
||||
<td>{item.currency || <span className="muted-note">—</span>}</td>
|
||||
<td className="num">{item.quantity === null || item.quantity === undefined ? '—' : item.quantity}</td>
|
||||
<td className="num">{item.moq === null || item.moq === undefined ? '—' : item.moq}</td>
|
||||
<td>
|
||||
<span className={`pill pill-${statusMeta.tone}`}>{statusMeta.label}</span>
|
||||
{item.parse_source === 'ai' && <span className="pill pill-mute src-pill">AI</span>}
|
||||
</td>
|
||||
<td className="import-note">{item.review_note || <span className="muted-note">—</span>}</td>
|
||||
<td className="col-actions">
|
||||
<button type="button" className="icon-btn" title="修正后入库" onClick={() => setEditing(true)}>
|
||||
<PencilIcon size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button" className="icon-btn" title="直接入库" disabled={committed || saving}
|
||||
onClick={commitOne}
|
||||
>
|
||||
<CheckIcon size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button" className="icon-btn" title="驳回" disabled={item.status === 'rejected' || saving}
|
||||
onClick={() => send('rejected')}
|
||||
>
|
||||
<CloseIcon size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function ContractCleanup() {
|
||||
const [jobs, setJobs] = useState([])
|
||||
const [activeJobId, setActiveJobId] = useState(null)
|
||||
const [jobDetail, setJobDetail] = useState(null)
|
||||
const [loadingJobs, setLoadingJobs] = useState(true)
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [selected, setSelected] = useState(() => new Set())
|
||||
const [committing, setCommitting] = useState(false)
|
||||
const [aiRunning, setAiRunning] = useState(false)
|
||||
const [commitReport, setCommitReport] = useState(null)
|
||||
const [originals, setOriginals] = useState(null)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const fileInputRef = useRef(null)
|
||||
const headPickRef = useRef(null)
|
||||
|
||||
const PAGE_LIMIT = 300
|
||||
|
||||
const loadJobs = useCallback(async (preferId) => {
|
||||
setLoadingJobs(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/contracts/import-jobs')
|
||||
const rows = Array.isArray(data) ? data : []
|
||||
setJobs(rows)
|
||||
setActiveJobId(current => {
|
||||
if (preferId) return preferId
|
||||
if (current && rows.some(row => row.id === current)) return current
|
||||
return rows[0]?.id ?? null
|
||||
})
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || '读取导入任务失败')
|
||||
} finally {
|
||||
setLoadingJobs(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadJobs() }, [loadJobs])
|
||||
|
||||
const loadDetail = useCallback(async (jobId) => {
|
||||
if (!jobId) { setJobDetail(null); return }
|
||||
setLoadingDetail(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}`)
|
||||
setJobDetail(data)
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || '读取任务明细失败')
|
||||
setJobDetail(null)
|
||||
} finally {
|
||||
setLoadingDetail(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail(activeJobId)
|
||||
setSelected(new Set())
|
||||
setCommitReport(null)
|
||||
setStatusFilter('')
|
||||
setShowAll(false)
|
||||
}, [activeJobId, loadDetail])
|
||||
|
||||
/* 原件只在服务器留 7 天,所以「还存在哪些原件」得单独问一次接口 */
|
||||
const loadOriginals = useCallback(async (jobId) => {
|
||||
if (!jobId) { setOriginals(null); return }
|
||||
try {
|
||||
const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}/files`)
|
||||
setOriginals(data)
|
||||
} catch {
|
||||
setOriginals(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadOriginals(activeJobId) }, [activeJobId, loadOriginals])
|
||||
|
||||
const upload = async (fileList) => {
|
||||
const files = [...(fileList || [])]
|
||||
if (!files.length) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const form = new FormData()
|
||||
files.forEach(file => form.append('files', file))
|
||||
const { data } = await axios.post('/api/contracts/import', form)
|
||||
const jobId = data?.job?.id
|
||||
await loadJobs(jobId)
|
||||
if (jobId) { await loadDetail(jobId); loadOriginals(jobId) }
|
||||
setNotice(`${files.length} 个文件已解析。${data?.message || ''}`)
|
||||
} catch (uploadError) {
|
||||
setError(uploadError?.response?.data?.error || '上传失败,请稍后重试')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const onDrop = (event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
upload(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
const refreshAfterReview = useCallback(async () => {
|
||||
await loadDetail(activeJobId)
|
||||
// 复核完最后一条,任务状态会从 pending_review 变 reviewed,列表也要跟着刷新
|
||||
await loadJobs(activeJobId)
|
||||
}, [activeJobId, loadDetail, loadJobs])
|
||||
|
||||
const refreshAfterCommit = useCallback(async () => {
|
||||
setSelected(new Set())
|
||||
await loadDetail(activeJobId)
|
||||
await loadJobs(activeJobId)
|
||||
}, [activeJobId, loadDetail, loadJobs])
|
||||
|
||||
const items = jobDetail?.items || []
|
||||
const filteredItems = useMemo(
|
||||
() => (statusFilter ? items.filter(item => item.status === statusFilter) : items),
|
||||
[items, statusFilter]
|
||||
)
|
||||
const shownItems = useMemo(
|
||||
() => (showAll ? filteredItems : filteredItems.slice(0, PAGE_LIMIT)),
|
||||
[filteredItems, showAll]
|
||||
)
|
||||
const counts = useMemo(() => {
|
||||
const result = { total: items.length, pending_review: 0, approved: 0, rejected: 0, committed: 0 }
|
||||
items.forEach(item => {
|
||||
result[item.status] = (result[item.status] || 0) + 1
|
||||
if (item.committed_at) result.committed += 1
|
||||
})
|
||||
return result
|
||||
}, [items])
|
||||
|
||||
const thumbs = useSkuThumbs(items.map(item => item.sku).filter(Boolean))
|
||||
|
||||
const pickableIds = useMemo(
|
||||
() => filteredItems.filter(item => !item.committed_at).map(item => item.id),
|
||||
[filteredItems]
|
||||
)
|
||||
const allPicked = pickableIds.length > 0 && pickableIds.every(id => selected.has(id))
|
||||
const somePicked = pickableIds.some(id => selected.has(id))
|
||||
|
||||
useEffect(() => {
|
||||
if (headPickRef.current) headPickRef.current.indeterminate = somePicked && !allPicked
|
||||
}, [somePicked, allPicked])
|
||||
|
||||
const togglePick = (id) => {
|
||||
setSelected(current => {
|
||||
const next = new Set(current)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const pickAll = () => setSelected(new Set(pickableIds))
|
||||
const invertPick = () => setSelected(current => {
|
||||
const next = new Set()
|
||||
pickableIds.forEach(id => { if (!current.has(id)) next.add(id) })
|
||||
return next
|
||||
})
|
||||
const clearPick = () => setSelected(new Set())
|
||||
|
||||
const commitSelected = async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) { setError('请先勾选要入库的明细'); return }
|
||||
if (!window.confirm(`把勾选的 ${ids.length} 条写进产品库?\n\n已存在的货号会更新采购价并记入调价历史,库里没有的货号会新建。`)) return
|
||||
setCommitting(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
setCommitReport(null)
|
||||
try {
|
||||
const { data } = await axios.post('/api/contracts/import-items/commit', { ids })
|
||||
setCommitReport(data)
|
||||
setNotice(data?.message || '入库完成')
|
||||
await refreshAfterCommit()
|
||||
} catch (commitError) {
|
||||
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
|
||||
} finally {
|
||||
setCommitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAiParse = async () => {
|
||||
if (!activeJobId) return
|
||||
if (!window.confirm('用模型重新解析这个任务?\n\n原来是规则解析出来的「待复核」行会被替换掉(已经复核或已入库的行不受影响)。')) return
|
||||
setAiRunning(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const { data } = await axios.post(`/api/contracts/import-jobs/${activeJobId}/ai-parse`)
|
||||
setNotice(data?.message || 'AI 解析完成')
|
||||
await refreshAfterCommit()
|
||||
} catch (aiError) {
|
||||
setError(aiError?.response?.data?.error || 'AI 解析失败')
|
||||
} finally {
|
||||
setAiRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadOriginal = async (name) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/contracts/import-jobs/${activeJobId}/file`, {
|
||||
params: { name }, responseType: 'blob'
|
||||
})
|
||||
const url = window.URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (downloadError) {
|
||||
setError('原件下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const activeJob = jobDetail?.job
|
||||
const jobMeta = activeJob ? (JOB_STATUS[activeJob.status] || { label: activeJob.status, tone: 'mute' }) : null
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">合同清洗中心</h1>
|
||||
<p className="page-sub">
|
||||
供应商合同批量导入 → 按字符规则认列(不认行列位置)→ 人工审核入库。
|
||||
规则认不出来的,可以交给 AI 解析。原件只在服务器保留 7 天后自动清理。
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => { loadJobs(activeJobId); loadDetail(activeJobId); loadOriginals(activeJobId) }}>
|
||||
<RefreshIcon size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className={`drop-zone${dragging ? ' is-dragging' : ''}${uploading ? ' is-busy' : ''}`}
|
||||
onDragOver={event => { event.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<span className="drop-zone-icon"><DownloadIcon size={26} /></span>
|
||||
<div className="drop-zone-text">
|
||||
<strong>{uploading ? '正在上传并解析…' : '把供应商合同 / 报价单拖到这里'}</strong>
|
||||
<span>
|
||||
支持 xlsx / xls / csv / tsv / txt。按标题里的字符特征认列(中英文与常见缩写都认),
|
||||
表头不在第一行也能识别;认不准的可以改用 AI 解析或人工补录。
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button" className="btn btn-primary btn-sm"
|
||||
disabled={uploading} onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
选择文件
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef} type="file" multiple hidden
|
||||
accept=".csv,.tsv,.txt,.xlsx,.xls,.xlsm"
|
||||
onChange={event => upload(event.target.files)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{error && <p className="notice-inline">{error}</p>}
|
||||
{notice && <p className="notice-inline notice-ok">{notice}</p>}
|
||||
{commitReport?.skipped?.length > 0 && (
|
||||
<div className="notice-inline">
|
||||
<strong>有 {commitReport.skipped.length} 条没入库:</strong>
|
||||
<ul className="skip-list">
|
||||
{commitReport.skipped.slice(0, 8).map(row => (
|
||||
<li key={row.id}>#{row.id}{row.sku ? `(${row.sku})` : ''}:{row.reason}</li>
|
||||
))}
|
||||
{commitReport.skipped.length > 8 && <li>…还有 {commitReport.skipped.length - 8} 条</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cleanup-layout">
|
||||
<aside className="cleanup-jobs">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><CleanupIcon size={17} /> 导入任务</h2>
|
||||
</div>
|
||||
</div>
|
||||
{loadingJobs ? (
|
||||
<p className="muted-note">读取中…</p>
|
||||
) : jobs.length === 0 ? (
|
||||
<p className="muted-note">还没有导入过合同。</p>
|
||||
) : (
|
||||
<ul className="job-list">
|
||||
{jobs.map(job => {
|
||||
const meta = JOB_STATUS[job.status] || { label: job.status, tone: 'mute' }
|
||||
return (
|
||||
<li key={job.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`job-card${activeJobId === job.id ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveJobId(job.id)}
|
||||
>
|
||||
<span className="job-no mono">{job.job_no}</span>
|
||||
<span className={`pill pill-${meta.tone}`}>{meta.label}</span>
|
||||
{job.parse_source === 'ai' && <span className="pill pill-mute src-pill">AI</span>}
|
||||
<span className="job-meta">
|
||||
{job.file_count} 个文件 · {formatMoment(job.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="cleanup-detail dashboard-panel">
|
||||
{!activeJob ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">选一个导入任务</p>
|
||||
<p className="empty-state-hint">左边点一个任务,这里会列出逐条解析结果供复核入库。</p>
|
||||
</div>
|
||||
) : loadingDetail ? (
|
||||
<p className="muted-note">读取明细中…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2 className="mono">{activeJob.job_no} <span className={`pill pill-${jobMeta.tone}`}>{jobMeta.label}</span></h2>
|
||||
<p className="panel-sub">
|
||||
解析方式 {activeJob.parse_source === 'ai' ? 'AI 模型' : '字符规则'} · 规则版本 {activeJob.rule_version} · 创建 {formatMoment(activeJob.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="chip-filters">
|
||||
<button
|
||||
type="button" className={`chip-filter${statusFilter === '' ? ' is-active' : ''}`}
|
||||
onClick={() => setStatusFilter('')}
|
||||
>
|
||||
全部 <b>{counts.total}</b>
|
||||
</button>
|
||||
{Object.entries(ITEM_STATUS).map(([value, meta]) => (
|
||||
counts[value] ? (
|
||||
<button
|
||||
type="button" key={value}
|
||||
className={`chip-filter${statusFilter === value ? ' is-active' : ''}`}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
>
|
||||
{meta.label} <b>{counts[value]}</b>
|
||||
</button>
|
||||
) : null
|
||||
))}
|
||||
{counts.committed > 0 && (
|
||||
<span className="chip-filter is-static">已入库 <b>{counts.committed}</b></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeJob.status === 'parse_failed' && activeJob.notes && (
|
||||
<p className="notice-inline">上一次解析中断在:{activeJob.notes}。可以修正文件表头后重新导入。</p>
|
||||
)}
|
||||
|
||||
<div className="cleanup-toolbar">
|
||||
<div className="cleanup-toolbar-left">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={pickAll} disabled={!pickableIds.length}>
|
||||
全选 <b>{pickableIds.length}</b>
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={invertPick} disabled={!pickableIds.length}>反选</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={clearPick} disabled={!selected.size}>清空</button>
|
||||
<span className="muted-note">已勾选 {selected.size} 条</span>
|
||||
</div>
|
||||
<div className="cleanup-toolbar-right">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={runAiParse} disabled={aiRunning}>
|
||||
<SparkIcon size={14} /> {aiRunning ? 'AI 解析中…' : 'AI 解析'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={commitSelected} disabled={committing || !selected.size}>
|
||||
<CheckIcon size={14} /> {committing ? '入库中…' : `入库选中(${selected.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="cleanup-meta" open={!counts.total}>
|
||||
<summary>解析详情(认了哪几列 / 原件留存)</summary>
|
||||
<div className="cleanup-meta-body">
|
||||
<div>
|
||||
<span className="meta-label">本次认列结果</span>
|
||||
<ColumnMapPanel raw={activeJob.column_map} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="meta-label">原件留存</span>
|
||||
{originals?.files?.length ? (
|
||||
<div className="originals">
|
||||
{originals.files.map(file => (
|
||||
<button type="button" key={file.name} className="original-chip"
|
||||
onClick={() => downloadOriginal(file.name)} title="下载原件核对">
|
||||
<ContractIcon size={13} /> {file.name}
|
||||
<em>{fmtSize(file.size)}</em>
|
||||
</button>
|
||||
))}
|
||||
<span className="muted-note">
|
||||
服务器只保留 {originals.retention_days} 天
|
||||
{originals.expires_at ? `(${new Date(originals.expires_at).toLocaleDateString('zh-CN')} 起自动清理)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted-note">
|
||||
{originals?.expired
|
||||
? `原件已过保留期(${originals.retention_days} 天)被自动清理 —— 解析结果与已入库的价格不受影响。`
|
||||
: '没有留存的原件。'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{counts.pending_review === 0 && counts.total > 0 && (
|
||||
<p className="notice-inline notice-ok">
|
||||
这个任务已经处理完毕{counts.committed ? `,其中 ${counts.committed} 条已入库` : ''}。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">
|
||||
{items.length ? '当前筛选下没有明细' : '这个任务没有解析出任何行'}
|
||||
</p>
|
||||
<p className="empty-state-hint">
|
||||
{items.length
|
||||
? '换个状态筛选看看。'
|
||||
: '规则没认出来。可以点上面的「AI 解析」让模型试一次,或点「修正」手工补录。'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table import-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-pick">
|
||||
<input
|
||||
ref={headPickRef} className="pick-box" type="checkbox"
|
||||
checked={allPicked} onChange={() => (allPicked ? clearPick() : pickAll())}
|
||||
aria-label="全选"
|
||||
/>
|
||||
</th>
|
||||
<th>来源</th><th>货号</th><th>产品名称</th><th>规格</th>
|
||||
<th className="num">采购价</th><th>币种</th><th className="num">数量</th><th className="num">起订量</th>
|
||||
<th>状态</th><th>复核备注</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shownItems.map(item => (
|
||||
<ReviewRow
|
||||
key={item.id} item={item} thumbs={thumbs}
|
||||
selected={selected.has(item.id)} onToggle={togglePick}
|
||||
onReviewed={refreshAfterReview} onCommitted={refreshAfterCommit}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredItems.length > shownItems.length && (
|
||||
<p className="muted-note table-more">
|
||||
还有 {filteredItems.length - shownItems.length} 条未显示
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowAll(true)}>全部显示</button>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContractCleanup
|
||||
@@ -0,0 +1,933 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
|
||||
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon
|
||||
} from '../components/Icons'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
const ACTIVITY_TYPES = ['邮件', '电话', '微信', 'WhatsApp', '拜访', '样品', '展会', '其他']
|
||||
|
||||
/* 联系人表单的空值模板:新增和「清空重置」共用一份,避免字段改动漏掉某处 */
|
||||
const EMPTY_CONTACT = {
|
||||
name: '', name_en: '', position: '', department: '',
|
||||
email: '', phone: '', whatsapp: '', wechat: '', is_primary: false, notes: ''
|
||||
}
|
||||
|
||||
const STATUS_LABELS = { active: '活跃', inactive: '非活跃', lead: '潜在' }
|
||||
|
||||
const INQUIRY_STATUS_LABELS = {
|
||||
draft: '草稿', quoting: '报价中', quoted: '已报价', won: '已成交', lost: '已流失', closed: '已关闭'
|
||||
}
|
||||
|
||||
const ORDER_STATUS_LABELS = {
|
||||
pending: '待处理', confirmed: '已确认', producing: '生产中', shipped: '已发货',
|
||||
completed: '已完成', cancelled: '已取消'
|
||||
}
|
||||
|
||||
function todayKey() {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function money(value, currency) {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return '—'
|
||||
return `${number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${currency ? ` ${currency}` : ''}`
|
||||
}
|
||||
|
||||
function formatMoment(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/** 下次跟进日期的语义化提示 —— 业务上「逾期」和「今天」要一眼能看出来 */
|
||||
function dueState(dateKey) {
|
||||
if (!dateKey) return null
|
||||
const today = todayKey()
|
||||
if (dateKey < today) return { tone: 'danger', label: '已逾期' }
|
||||
if (dateKey === today) return { tone: 'warn', label: '今天' }
|
||||
return { tone: 'ok', label: '待跟进' }
|
||||
}
|
||||
|
||||
/** 快速记录跟进:默认收起成一个输入框,聚焦或输入时展开完整字段 */
|
||||
function QuickFollowUp({ customer, contacts, inquiries, onSaved, canEdit }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
activity_type: ACTIVITY_TYPES[0], content: '', next_action: '',
|
||||
next_action_date: '', contact_id: '', inquiry_id: ''
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
const content = form.content.trim()
|
||||
if (!content) { setError('写点什么吧,至少留下一句跟进内容'); return }
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await axios.post(`/api/customers/${customer.id}/follow-ups`, {
|
||||
activity_type: form.activity_type,
|
||||
content,
|
||||
next_action: form.next_action.trim() || null,
|
||||
next_action_date: form.next_action_date || null,
|
||||
contact_id: form.contact_id ? Number(form.contact_id) : null,
|
||||
inquiry_id: form.inquiry_id ? Number(form.inquiry_id) : null
|
||||
})
|
||||
setForm({
|
||||
activity_type: ACTIVITY_TYPES[0], content: '', next_action: '',
|
||||
next_action_date: '', contact_id: '', inquiry_id: ''
|
||||
})
|
||||
setOpen(false)
|
||||
onSaved()
|
||||
} catch (submitError) {
|
||||
setError(submitError?.response?.data?.error || '记录失败,请稍后重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={`quick-follow${open ? ' is-open' : ''}`} onSubmit={submit}>
|
||||
<div className="quick-follow-head">
|
||||
<span className="quick-follow-icon"><PlusCircleIcon size={15} /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.content}
|
||||
placeholder="快速记录一条跟进,回车保存"
|
||||
aria-label="快速记录跟进内容"
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={event => setForm({ ...form, content: event.target.value })}
|
||||
/>
|
||||
{!open && canEdit && <button type="submit" className="btn btn-primary btn-sm" disabled={saving}>记录</button>}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="quick-follow-body">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>跟进方式</label>
|
||||
<select
|
||||
value={form.activity_type}
|
||||
onChange={event => setForm({ ...form, activity_type: event.target.value })}
|
||||
>
|
||||
{ACTIVITY_TYPES.map(type => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>对接联系人</label>
|
||||
<select
|
||||
value={form.contact_id}
|
||||
onChange={event => setForm({ ...form, contact_id: event.target.value })}
|
||||
>
|
||||
<option value="">不指定</option>
|
||||
{contacts.map(contact => (
|
||||
<option key={contact.id} value={contact.id}>
|
||||
{contact.name}{contact.position ? ` · ${contact.position}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>下一次跟进动作</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.next_action}
|
||||
placeholder="例如:电话确认包装要求"
|
||||
onChange={event => setForm({ ...form, next_action: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>下一次跟进日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.next_action_date}
|
||||
onChange={event => setForm({ ...form, next_action_date: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{inquiries.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label>关联询盘</label>
|
||||
<select
|
||||
value={form.inquiry_id}
|
||||
onChange={event => setForm({ ...form, inquiry_id: event.target.value })}
|
||||
>
|
||||
<option value="">不关联</option>
|
||||
{inquiries.map(inquiry => (
|
||||
<option key={inquiry.id} value={inquiry.id}>
|
||||
{inquiry.inquiry_no} · {inquiry.title || '未命名'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="quick-follow-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => { setOpen(false); setError('') }}>
|
||||
收起
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
|
||||
{saving ? '保存中…' : '保存跟进'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!open && error && <p className="form-error">{error}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** 编辑一条跟进记录 —— 字段与快速记录保持一致,避免两套表单口径不一 */
|
||||
function FollowUpEditModal({ record, contacts, inquiries, onClose, onSaved }) {
|
||||
const [form, setForm] = useState({
|
||||
activity_type: record.activity_type || ACTIVITY_TYPES[0],
|
||||
content: record.content || '',
|
||||
next_action: record.next_action || '',
|
||||
next_action_date: record.next_action_date ? String(record.next_action_date).slice(0, 10) : '',
|
||||
contact_id: record.contact_id || '',
|
||||
inquiry_id: record.inquiry_id || ''
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async event => {
|
||||
event.preventDefault()
|
||||
const content = form.content.trim()
|
||||
if (!content) { setError('跟进内容不能为空'); return }
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await axios.patch(`/api/customers/${record.customer_id}/follow-ups/${record.id}`, {
|
||||
activity_type: form.activity_type,
|
||||
content,
|
||||
next_action: form.next_action.trim() || null,
|
||||
next_action_date: form.next_action_date || null,
|
||||
contact_id: form.contact_id ? Number(form.contact_id) : null,
|
||||
inquiry_id: form.inquiry_id ? Number(form.inquiry_id) : null
|
||||
})
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || '保存失败,请稍后重试')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>编辑跟进记录</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="modal-body">
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>跟进方式</label>
|
||||
<select value={form.activity_type} onChange={e => setForm({ ...form, activity_type: e.target.value })}>
|
||||
{ACTIVITY_TYPES.map(type => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>对接联系人</label>
|
||||
<select value={form.contact_id} onChange={e => setForm({ ...form, contact_id: e.target.value })}>
|
||||
<option value="">不指定</option>
|
||||
{contacts.map(contact => (
|
||||
<option key={contact.id} value={contact.id}>
|
||||
{contact.name}{contact.position ? ` · ${contact.position}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>跟进内容 *</label>
|
||||
<textarea rows="3" value={form.content} onChange={e => setForm({ ...form, content: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>下一次跟进动作</label>
|
||||
<input
|
||||
type="text" value={form.next_action}
|
||||
placeholder="例如:电话确认包装要求"
|
||||
onChange={e => setForm({ ...form, next_action: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>下一次跟进日期</label>
|
||||
<input
|
||||
type="date" value={form.next_action_date}
|
||||
onChange={e => setForm({ ...form, next_action_date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{inquiries.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label>关联询盘</label>
|
||||
<select value={form.inquiry_id} onChange={e => setForm({ ...form, inquiry_id: e.target.value })}>
|
||||
<option value="">不关联</option>
|
||||
{inquiries.map(inquiry => (
|
||||
<option key={inquiry.id} value={inquiry.id}>
|
||||
{inquiry.inquiry_no} · {inquiry.title || '未命名'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '保存中…' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */
|
||||
function CustomerProfileModal({ customer, onClose, onSaved }) {
|
||||
const [form, setForm] = useState({
|
||||
company: customer.company || '',
|
||||
company_en: customer.company_en || '',
|
||||
country: customer.country || '',
|
||||
industry: customer.industry || '',
|
||||
website: customer.website || '',
|
||||
address: customer.address || '',
|
||||
email: customer.email || '',
|
||||
phone: customer.phone || '',
|
||||
notes: customer.notes || '',
|
||||
status: customer.status === 'inactive' ? 'inactive' : 'active'
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
|
||||
const submit = async event => {
|
||||
event.preventDefault()
|
||||
if (!form.company.trim()) { setError('公司名称不能为空'); return }
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await axios.patch(`/api/customers/${customer.id}`, { ...form, company: form.company.trim() })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || '保存失败,请稍后重试')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>编辑客户信息</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="modal-body">
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>公司名称 *</label>
|
||||
<input type="text" value={form.company} onChange={e => set('company', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>英文名称</label>
|
||||
<input type="text" value={form.company_en} onChange={e => set('company_en', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>国家 / 地区</label>
|
||||
<input type="text" value={form.country} onChange={e => set('country', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>行业</label>
|
||||
<input type="text" value={form.industry} onChange={e => set('industry', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>网站</label>
|
||||
<input type="text" value={form.website} onChange={e => set('website', e.target.value)} placeholder="https://" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>地址</label>
|
||||
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} placeholder="报价单抬头会用到" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>邮箱</label>
|
||||
<input type="email" value={form.email} onChange={e => set('email', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>电话</label>
|
||||
<input type="text" value={form.phone} onChange={e => set('phone', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>客户状态</label>
|
||||
<select value={form.status} onChange={e => set('status', e.target.value)}>
|
||||
<option value="active">活跃(可正常跟进)</option>
|
||||
<option value="inactive">非活跃</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea rows="2" value={form.notes} onChange={e => set('notes', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '保存中…' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerDetail() {
|
||||
const { id } = useParams()
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'customer.edit')
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [contactOpen, setContactOpen] = useState(false)
|
||||
/* 为 null = 新增联系人;有值 = 正在编辑这条联系人 */
|
||||
const [contactEditing, setContactEditing] = useState(null)
|
||||
const [contactForm, setContactForm] = useState({ ...EMPTY_CONTACT })
|
||||
const [contactError, setContactError] = useState('')
|
||||
const [savingContact, setSavingContact] = useState(false)
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
/* 正在编辑的跟进记录;null = 没打开 */
|
||||
const [followUpEditing, setFollowUpEditing] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await axios.get(`/api/customers/${id}`)
|
||||
setData(response.data)
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || '读取客户详情失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
/* 打开联系人弹窗:不传参数是新增,传了联系人就是编辑 */
|
||||
const openContact = (contact = null) => {
|
||||
setContactEditing(contact)
|
||||
setContactError('')
|
||||
setContactForm(contact ? {
|
||||
name: contact.name || '',
|
||||
name_en: contact.name_en || '',
|
||||
position: contact.position || '',
|
||||
department: contact.department || '',
|
||||
email: contact.email || '',
|
||||
phone: contact.phone || '',
|
||||
whatsapp: contact.whatsapp || '',
|
||||
wechat: contact.wechat || '',
|
||||
is_primary: !!contact.is_primary,
|
||||
notes: contact.notes || ''
|
||||
} : { ...EMPTY_CONTACT })
|
||||
setContactOpen(true)
|
||||
}
|
||||
|
||||
const closeContact = () => {
|
||||
setContactOpen(false)
|
||||
setContactEditing(null)
|
||||
setContactError('')
|
||||
setContactForm({ ...EMPTY_CONTACT })
|
||||
}
|
||||
|
||||
const submitContact = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!contactForm.name.trim()) { setContactError('联系人姓名不能为空'); return }
|
||||
setSavingContact(true)
|
||||
setContactError('')
|
||||
try {
|
||||
const payload = { ...contactForm, name: contactForm.name.trim() }
|
||||
if (contactEditing) await axios.patch(`/api/customers/${id}/contacts/${contactEditing.id}`, payload)
|
||||
else await axios.post(`/api/customers/${id}/contacts`, payload)
|
||||
closeContact()
|
||||
await load()
|
||||
} catch (submitError) {
|
||||
setContactError(submitError?.response?.data?.error || (contactEditing ? '保存联系人失败' : '新增联系人失败'))
|
||||
} finally {
|
||||
setSavingContact(false)
|
||||
}
|
||||
}
|
||||
|
||||
const customer = data?.customer
|
||||
const followups = data?.followups || []
|
||||
const contacts = data?.contacts || []
|
||||
const inquiries = data?.inquiries || []
|
||||
|
||||
// 下一次跟进 = 还没过期或被跳过的记录里最近的那一条
|
||||
const nextFollowUp = useMemo(() => {
|
||||
const dated = followups.filter(record => record.next_action_date)
|
||||
if (!dated.length) return null
|
||||
return [...dated].sort((a, b) => String(a.next_action_date).localeCompare(String(b.next_action_date)))
|
||||
.find(record => record.next_action_date >= todayKey()) || dated[0]
|
||||
}, [followups])
|
||||
|
||||
if (loading) return <div className="loading">加载客户详情…</div>
|
||||
if (!customer) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<p className="notice-inline">{error || '客户不存在'}</p>
|
||||
<Link className="btn btn-secondary btn-sm" to="/customers">返回客户列表</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const nextDue = nextFollowUp ? dueState(String(nextFollowUp.next_action_date).slice(0, 10)) : null
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/customers">客户管理</Link>
|
||||
<span>/</span>
|
||||
<span>{customer.company}</span>
|
||||
</div>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{customer.company}</h1>
|
||||
<p className="page-sub">
|
||||
{[customer.country, customer.industry].filter(Boolean).join(' · ') || '未填写国家与行业'}
|
||||
{' · '}
|
||||
<span className={`pill pill-${customer.status === 'active' ? 'ok' : 'mute'}`}>
|
||||
{STATUS_LABELS[customer.status] || customer.status || '未标记'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
|
||||
<RefreshIcon size={14} /> 刷新
|
||||
</button>
|
||||
<Link className="btn btn-primary btn-sm" to="/inquiries">
|
||||
<InquiryIcon size={15} /> 去询盘报价
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="notice-inline">{error}</p>}
|
||||
|
||||
<div className="stat-rail inquiry-rail">
|
||||
<div className="stat-line">
|
||||
<span className="stat-line-icon"><ClockIcon size={17} /></span>
|
||||
<span>最近联系</span>
|
||||
<strong>{customer.last_contact_at ? formatMoment(customer.last_contact_at).slice(0, 16) : '—'}</strong>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-line-icon"><TargetIcon size={17} /></span>
|
||||
<span>下次跟进</span>
|
||||
<strong>
|
||||
{nextFollowUp
|
||||
? <>{String(nextFollowUp.next_action_date).slice(0, 10)} <span className={`pill pill-${nextDue?.tone || 'mute'}`}>{nextDue?.label}</span></>
|
||||
: '未安排'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
|
||||
<span>询盘</span><strong>{inquiries.length}</strong>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-line-icon"><QuoteIcon size={17} /></span>
|
||||
<span>报价单</span>
|
||||
<strong>
|
||||
{data.quoteOverview?.count || 0}
|
||||
{Number(data.quoteOverview?.total_amount) > 0 && ` · ${money(data.quoteOverview.total_amount)}`}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-line-icon"><ClipboardIcon size={17} /></span>
|
||||
<span>订单</span>
|
||||
<strong>
|
||||
{data.orderOverview?.count || 0}
|
||||
{Number(data.orderOverview?.total_amount) > 0 && ` · ${money(data.orderOverview.total_amount)}`}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<div className="detail-main">
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><ClockIcon size={17} /> 跟进时间线</h2>
|
||||
</div>
|
||||
<span className="panel-period">{followups.length} 条记录</span>
|
||||
</div>
|
||||
|
||||
<QuickFollowUp
|
||||
customer={customer}
|
||||
contacts={contacts}
|
||||
inquiries={inquiries}
|
||||
onSaved={load}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
|
||||
{followups.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">还没有跟进记录</p>
|
||||
<p className="empty-state-hint">上面那一行就是快速记录入口,写一句话按回车即可。</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="timeline">
|
||||
{followups.map(record => {
|
||||
const due = dueState(String(record.next_action_date || '').slice(0, 10))
|
||||
return (
|
||||
<li className="timeline-item" key={record.id}>
|
||||
<span className="timeline-dot" />
|
||||
<div className="timeline-body">
|
||||
<div className="timeline-head">
|
||||
<span className="timeline-type">{record.activity_type}</span>
|
||||
<span className="timeline-time">{formatMoment(record.created_at)}</span>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-edit"
|
||||
onClick={() => setFollowUpEditing(record)}
|
||||
title="编辑这条跟进"
|
||||
aria-label="编辑这条跟进"
|
||||
>
|
||||
<PencilIcon size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="timeline-content">{record.content}</p>
|
||||
{(record.next_action || record.next_action_date) && (
|
||||
<div className="timeline-next">
|
||||
<TargetIcon size={13} />
|
||||
<span>
|
||||
下次:{record.next_action || '跟进'}
|
||||
{record.next_action_date ? ` · ${String(record.next_action_date).slice(0, 10)}` : ''}
|
||||
</span>
|
||||
{due && <span className={`pill pill-${due.tone}`}>{due.label}</span>}
|
||||
</div>
|
||||
)}
|
||||
{(record.contact_name || record.inquiry_id) && (
|
||||
<div className="timeline-meta">
|
||||
{record.contact_name && <span>对接:{record.contact_name}</span>}
|
||||
{record.inquiry_id && (
|
||||
<Link to={`/inquiries/${record.inquiry_id}`}>关联询盘 #{record.inquiry_id}</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><InquiryIcon size={17} /> 关联询盘</h2>
|
||||
</div>
|
||||
<Link className="btn btn-secondary btn-sm" to="/inquiries">全部询盘</Link>
|
||||
</div>
|
||||
{inquiries.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">还没有询盘</p>
|
||||
<p className="empty-state-hint">在「询盘管理」里新建一条,把它挂到当前客户名下。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>询盘号</th><th>标题</th><th>日期</th><th>币种</th><th>状态</th><th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inquiries.map(inquiry => (
|
||||
<tr key={inquiry.id}>
|
||||
<td className="mono bold">{inquiry.inquiry_no}</td>
|
||||
<td>{inquiry.title || <span className="muted-note">未命名</span>}</td>
|
||||
<td className="mono">{inquiry.inquiry_date}</td>
|
||||
<td>{inquiry.currency || '—'}</td>
|
||||
<td>{INQUIRY_STATUS_LABELS[inquiry.status] || inquiry.status}</td>
|
||||
<td className="col-actions">
|
||||
<Link className="btn-link" to={`/inquiries/${inquiry.id}`}>查看报价</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><QuoteIcon size={17} /> 报价单与订单</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="split-lists">
|
||||
<div>
|
||||
<h4 className="split-title">客户报价单</h4>
|
||||
{(data.quoteItems || []).length === 0 ? (
|
||||
<p className="muted-note">还没有正式报价单。</p>
|
||||
) : (
|
||||
<ul className="mini-list">
|
||||
{data.quoteItems.map(quote => (
|
||||
<li key={quote.id}>
|
||||
<span className="mono">{quote.quote_no}</span>
|
||||
<span className="mini-list-sub">{quote.quote_date}</span>
|
||||
<b>{money(quote.total_amount, quote.currency)}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="split-title">订单</h4>
|
||||
{(data.orderItems || []).length === 0 ? (
|
||||
<p className="muted-note">还没有成交订单。</p>
|
||||
) : (
|
||||
<ul className="mini-list">
|
||||
{data.orderItems.map(order => (
|
||||
<li key={order.id}>
|
||||
<span className="mono">{order.order_no}</span>
|
||||
<span className="mini-list-sub">
|
||||
{order.order_date} · {ORDER_STATUS_LABELS[order.status] || order.status}
|
||||
</span>
|
||||
<b>{money(order.total_amount, order.currency)}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="detail-side">
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2>基本信息</h2>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setProfileOpen(true)}>
|
||||
<PencilIcon size={14} /> 编辑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="kv-grid kv-grid-single">
|
||||
<div className="kv"><span className="kv-label">公司名称</span><span className="kv-value">{customer.company}</span></div>
|
||||
<div className="kv"><span className="kv-label">国家 / 地区</span><span className={`kv-value${customer.country ? '' : ' is-empty'}`}>{customer.country || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">行业</span><span className={`kv-value${customer.industry ? '' : ' is-empty'}`}>{customer.industry || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">网站</span><span className="kv-value">
|
||||
{customer.website
|
||||
? <a href={customer.website} target="_blank" rel="noopener noreferrer">{customer.website}</a>
|
||||
: <span className="is-empty">未填写</span>}
|
||||
</span></div>
|
||||
<div className="kv"><span className="kv-label">邮箱</span><span className={`kv-value${customer.email ? '' : ' is-empty'}`}>{customer.email || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">电话</span><span className={`kv-value${customer.phone ? '' : ' is-empty'}`}>{customer.phone || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">负责人</span><span className={`kv-value${customer.owner_name ? '' : ' is-empty'}`}>{customer.owner_name || '未指派'}</span></div>
|
||||
<div className="kv"><span className="kv-label">建档时间</span><span className="kv-value">{formatMoment(customer.created_at).slice(0, 10)}</span></div>
|
||||
</div>
|
||||
{customer.notes && <p className="notes-box">{customer.notes}</p>}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><UsersIcon size={17} /> 联系人</h2>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openContact()}>
|
||||
<PlusCircleIcon size={14} /> 新增
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{contacts.length === 0 ? (
|
||||
<p className="muted-note">还没有联系人。报价和跟进都挂在具体的人身上,建议先补一个。</p>
|
||||
) : (
|
||||
<ul className="contact-list">
|
||||
{contacts.map(contact => (
|
||||
<li className="contact-card" key={contact.id}>
|
||||
<div className="contact-head">
|
||||
<span className="contact-avatar">{(contact.name || '?').slice(0, 1)}</span>
|
||||
<div>
|
||||
<div className="contact-name">
|
||||
{contact.name}
|
||||
{contact.is_primary ? <span className="pill pill-ok">主要</span> : null}
|
||||
</div>
|
||||
<div className="contact-role">
|
||||
{[contact.position, contact.department].filter(Boolean).join(' · ') || '未填写职位'}
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="contact-edit"
|
||||
onClick={() => openContact(contact)}
|
||||
title="编辑联系人"
|
||||
aria-label={`编辑联系人 ${contact.name}`}
|
||||
>
|
||||
<PencilIcon size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-lines">
|
||||
{contact.email && <span>✉ {contact.email}</span>}
|
||||
{contact.phone && <span>☎ {contact.phone}</span>}
|
||||
{contact.whatsapp && <span>WA {contact.whatsapp}</span>}
|
||||
{contact.wechat && <span>微信 {contact.wechat}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{contactOpen && (
|
||||
<div className="modal-overlay" onClick={closeContact}>
|
||||
<div className="modal-content" onClick={event => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{contactEditing ? '编辑联系人' : '新增联系人'}</h2>
|
||||
<button type="button" className="icon-btn" onClick={closeContact} aria-label="关闭">×</button>
|
||||
</div>
|
||||
<form onSubmit={submitContact}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>姓名 *</label>
|
||||
<input
|
||||
type="text" required value={contactForm.name}
|
||||
onChange={event => setContactForm({ ...contactForm, name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>英文名</label>
|
||||
<input
|
||||
type="text" value={contactForm.name_en}
|
||||
onChange={event => setContactForm({ ...contactForm, name_en: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>职位</label>
|
||||
<input
|
||||
type="text" value={contactForm.position}
|
||||
onChange={event => setContactForm({ ...contactForm, position: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>部门</label>
|
||||
<input
|
||||
type="text" value={contactForm.department}
|
||||
onChange={event => setContactForm({ ...contactForm, department: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>邮箱</label>
|
||||
<input
|
||||
type="email" value={contactForm.email}
|
||||
onChange={event => setContactForm({ ...contactForm, email: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>电话</label>
|
||||
<input
|
||||
type="text" value={contactForm.phone}
|
||||
onChange={event => setContactForm({ ...contactForm, phone: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>WhatsApp</label>
|
||||
<input
|
||||
type="text" value={contactForm.whatsapp}
|
||||
onChange={event => setContactForm({ ...contactForm, whatsapp: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>微信</label>
|
||||
<input
|
||||
type="text" value={contactForm.wechat}
|
||||
onChange={event => setContactForm({ ...contactForm, wechat: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
rows={2} value={contactForm.notes}
|
||||
onChange={event => setContactForm({ ...contactForm, notes: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="check-inline">
|
||||
<input
|
||||
type="checkbox" checked={contactForm.is_primary}
|
||||
onChange={event => setContactForm({ ...contactForm, is_primary: event.target.checked })}
|
||||
/>
|
||||
设为主要联系人
|
||||
</label>
|
||||
{contactError && <p className="form-error">{contactError}</p>}
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={closeContact}>取消</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={savingContact}>
|
||||
{savingContact ? '保存中…' : (contactEditing ? '保存修改' : '保存')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profileOpen && (
|
||||
<CustomerProfileModal
|
||||
customer={customer}
|
||||
onClose={() => setProfileOpen(false)}
|
||||
onSaved={async () => { setProfileOpen(false); await load() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{followUpEditing && (
|
||||
<FollowUpEditModal
|
||||
record={followUpEditing}
|
||||
contacts={contacts}
|
||||
inquiries={inquiries}
|
||||
onClose={() => setFollowUpEditing(null)}
|
||||
onSaved={async () => { setFollowUpEditing(null); await load() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerDetail
|
||||
@@ -1,10 +1,33 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { PlusCircleIcon, RefreshIcon, SearchIcon, GlobeIcon, UserIcon } from '../components/Icons'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
const STATUS_LABELS = { active: '活跃', inactive: '非活跃', lead: '潜在' }
|
||||
|
||||
/**
|
||||
* 我的客户。
|
||||
*
|
||||
* 归属口径(Wayne 2026-09-14):客户有「所属」,谁建的归谁,默认只有归属人看得见;
|
||||
* 创建人主动丢进公海后同组织可见可领;其余客户只有拿到 customer.view_all 的
|
||||
* 最高级别管理账号能看到 —— 所以这里默认只查 scope=mine,
|
||||
* 有权限的账号多一个「全部客户」看板。
|
||||
*/
|
||||
function CustomerManagement() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const canViewAll = hasPermission(user, 'customer.view_all')
|
||||
const canEdit = hasPermission(user, 'customer.edit')
|
||||
const [scope, setScope] = useState('mine')
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [formData, setFormData] = useState({
|
||||
company: '',
|
||||
country: '',
|
||||
@@ -15,56 +38,152 @@ function CustomerManagement() {
|
||||
notes: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
const fetchCustomers = useCallback(async (nextScope = scope) => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.get('/api/customers')
|
||||
setCustomers(response.data)
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error)
|
||||
const response = await axios.get('/api/customers', { params: { scope: nextScope } })
|
||||
setCustomers(Array.isArray(response.data) ? response.data : [])
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || '获取客户列表失败')
|
||||
setCustomers([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [scope])
|
||||
|
||||
useEffect(() => { fetchCustomers(scope) }, [scope, fetchCustomers])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase()
|
||||
if (!term) return customers
|
||||
return customers.filter(customer =>
|
||||
[customer.company, customer.country, customer.industry, customer.email, customer.phone, customer.owner_name]
|
||||
.filter(Boolean)
|
||||
.some(value => String(value).toLowerCase().includes(term))
|
||||
)
|
||||
}, [customers, keyword])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setFormError('')
|
||||
try {
|
||||
await axios.post('/api/customers', formData)
|
||||
const response = await axios.post('/api/customers', formData)
|
||||
setShowAddModal(false)
|
||||
setFormData({
|
||||
company: '',
|
||||
country: '',
|
||||
industry: '',
|
||||
website: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: ''
|
||||
})
|
||||
fetchCustomers()
|
||||
} catch (error) {
|
||||
console.error('创建客户失败:', error)
|
||||
alert('创建失败: ' + error.message)
|
||||
setFormData({ company: '', country: '', industry: '', website: '', email: '', phone: '', notes: '' })
|
||||
const newId = response.data?.id
|
||||
await fetchCustomers(scope)
|
||||
// 新建后直接进详情页 —— 下一步九成是记第一条跟进,不该让用户再自己找
|
||||
if (newId) navigate(`/customers/${newId}`)
|
||||
} catch (submitError) {
|
||||
setFormError(submitError?.response?.data?.error || submitError?.message || '创建失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载中...</div>
|
||||
/* 丢入公海:归属人主动放弃,客户变成组织共享资源,同组织同事可见可领。
|
||||
乐观更新:先把行从列表拿掉(mine 范围)或打上公海标记(all 范围),
|
||||
后台失败再放回原位 —— 用户点完立刻看到结果,不用等网络往返。 */
|
||||
const handleToPool = async (customer) => {
|
||||
const ok = window.confirm(
|
||||
`把「${customer.company}」丢进客户公海?\n\n` +
|
||||
'丢入后它会从「我的客户」移到「客户公海」,同组织的同事都能看到并领取。\n' +
|
||||
'一旦有同事领取,这条客户就不再归你了。'
|
||||
)
|
||||
if (!ok) return
|
||||
setError('')
|
||||
const index = customers.findIndex(item => item.id === customer.id)
|
||||
const rollback = () => setCustomers(prev => {
|
||||
if (prev.some(item => item.id === customer.id)) {
|
||||
return prev.map(item => (item.id === customer.id ? customer : item))
|
||||
}
|
||||
const next = [...prev]
|
||||
next.splice(Math.min(Math.max(index, 0), next.length), 0, customer)
|
||||
return next
|
||||
})
|
||||
if (scope === 'mine') {
|
||||
setCustomers(prev => prev.filter(item => item.id !== customer.id))
|
||||
} else {
|
||||
setCustomers(prev => prev.map(item => (item.id === customer.id ? { ...item, pool_status: 'public' } : item)))
|
||||
}
|
||||
try {
|
||||
await axios.post(`/api/customers/${customer.id}/to-pool`)
|
||||
} catch (err) {
|
||||
rollback()
|
||||
setError(err?.response?.data?.error || '丢入公海失败,已恢复原状')
|
||||
}
|
||||
}
|
||||
|
||||
const openAdd = () => {
|
||||
setFormError('')
|
||||
setShowAddModal(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">客户管理</h1>
|
||||
<button className="btn-primary" onClick={() => setShowAddModal(true)}>
|
||||
+ 新增客户
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="page-title">我的客户</h1>
|
||||
<p className="page-sub">
|
||||
客户默认只有创建人(归属人)可见。想让团队一起跟,就把客户丢进「客户公海」,
|
||||
同组织成员都能看到并领取。
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => fetchCustomers(scope)}>
|
||||
<RefreshIcon size={14} /> 刷新
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-primary btn-sm" onClick={openAdd}>
|
||||
<PlusCircleIcon size={15} /> 新增客户
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<div className="filter-bar">
|
||||
{canViewAll ? (
|
||||
<div className="scope-tabs" role="tablist" aria-label="客户范围">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={scope === 'mine'}
|
||||
className={`scope-tab${scope === 'mine' ? ' is-active' : ''}`}
|
||||
onClick={() => setScope('mine')}
|
||||
>
|
||||
我的客户
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={scope === 'all'}
|
||||
className={`scope-tab${scope === 'all' ? ' is-active' : ''}`}
|
||||
onClick={() => setScope('all')}
|
||||
>
|
||||
全部客户
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="scope-hint"><UserIcon size={13} /> 仅显示我名下的客户</span>
|
||||
)}
|
||||
|
||||
<span className="toolbar-search">
|
||||
<SearchIcon size={15} />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
placeholder="搜公司 / 国家 / 行业 / 联系方式"
|
||||
aria-label="搜索客户"
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="notice-inline">{error}</p>}
|
||||
|
||||
<div className="table-container table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -73,57 +192,99 @@ function CustomerManagement() {
|
||||
<th>行业</th>
|
||||
<th>网站</th>
|
||||
<th>联系方式</th>
|
||||
<th>负责人</th>
|
||||
<th>归属</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.length === 0 ? (
|
||||
{loading ? (
|
||||
/* 骨架屏:列宽贴近真实表格,加载完成时整页不会跳一下 */
|
||||
<SkeletonTableRows rows={6} widths={[2.4, 1, 1.2, 1.6, 1.8, 1, 1, 1.2]} />
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="8" className="empty-state">暂无客户数据</td>
|
||||
<td colSpan="8" className="empty-state">
|
||||
{customers.length
|
||||
? '没有匹配的客户'
|
||||
: scope === 'all'
|
||||
? '系统里还没有客户'
|
||||
: '你名下还没有客户。点右上角「新增客户」建档,或到「客户公海」领取同事放出的资源。'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
customers.map(customer => (
|
||||
<tr key={customer.id}>
|
||||
<td><strong>{customer.company}</strong></td>
|
||||
<td>{customer.country || '-'}</td>
|
||||
<td>{customer.industry || '-'}</td>
|
||||
<td>
|
||||
{customer.website ? (
|
||||
<a href={customer.website} target="_blank" rel="noopener noreferrer">
|
||||
{customer.website}
|
||||
</a>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{customer.email && <div>{customer.email}</div>}
|
||||
{customer.phone && <div>{customer.phone}</div>}
|
||||
</td>
|
||||
<td>{customer.owner_name || '-'}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${customer.status}`}>
|
||||
{customer.status === 'active' ? '活跃' : '非活跃'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
filtered.map(customer => {
|
||||
const isMine = user && Number(customer.owner_id) === Number(user.id)
|
||||
const inPool = customer.pool_status === 'public'
|
||||
const canDrop = (isMine || canViewAll) && !inPool && canEdit
|
||||
return (
|
||||
<tr key={customer.id} onClick={() => navigate(`/customers/${customer.id}`)}>
|
||||
<td><strong>{customer.company}</strong></td>
|
||||
<td>{customer.country || '-'}</td>
|
||||
<td>{customer.industry || '-'}</td>
|
||||
<td>
|
||||
{customer.website ? (
|
||||
<a
|
||||
href={customer.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
{customer.website}
|
||||
</a>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{customer.email && <div>{customer.email}</div>}
|
||||
{customer.phone && <div>{customer.phone}</div>}
|
||||
</td>
|
||||
<td>
|
||||
{inPool ? (
|
||||
<span className="pill pill-mute" title="已丢入公海,同组织成员可见可领">
|
||||
<GlobeIcon size={11} /> 公海
|
||||
</span>
|
||||
) : (
|
||||
<span className="owner-cell">
|
||||
<UserIcon size={11} /> {customer.owner_name || '未指定'}
|
||||
{isMine && <span className="owner-self">我</span>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`pill pill-${customer.status === 'active' ? 'ok' : 'mute'}`}>
|
||||
{STATUS_LABELS[customer.status] || customer.status || '未标记'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
{canDrop && (
|
||||
<button
|
||||
className="btn-link"
|
||||
title="放弃归属,同组织成员可见并可领取"
|
||||
onClick={event => { event.stopPropagation(); handleToPool(customer) }}
|
||||
>
|
||||
丢入公海
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={event => { event.stopPropagation(); navigate(`/customers/${customer.id}`) }}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 新增客户模态框 */}
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>新增客户</h2>
|
||||
<button className="modal-close" onClick={() => setShowAddModal(false)}>×</button>
|
||||
<button className="icon-btn" onClick={() => setShowAddModal(false)} aria-label="关闭">×</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
@@ -132,16 +293,19 @@ function CustomerManagement() {
|
||||
type="text"
|
||||
required
|
||||
value={formData.company}
|
||||
onChange={e => setFormData({...formData, company: e.target.value})}
|
||||
onChange={e => setFormData({ ...formData, company: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<p className="form-hint">
|
||||
新建的客户归属你自己:只有你能看到,除非你把它丢进客户公海。
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>国家</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={e => setFormData({...formData, country: e.target.value})}
|
||||
onChange={e => setFormData({ ...formData, country: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -149,7 +313,7 @@ function CustomerManagement() {
|
||||
<input
|
||||
type="text"
|
||||
value={formData.industry}
|
||||
onChange={e => setFormData({...formData, industry: e.target.value})}
|
||||
onChange={e => setFormData({ ...formData, industry: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +323,7 @@ function CustomerManagement() {
|
||||
type="url"
|
||||
placeholder="https://"
|
||||
value={formData.website}
|
||||
onChange={e => setFormData({...formData, website: e.target.value})}
|
||||
onChange={e => setFormData({ ...formData, website: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
@@ -168,7 +332,7 @@ function CustomerManagement() {
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({...formData, email: e.target.value})}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -176,7 +340,7 @@ function CustomerManagement() {
|
||||
<input
|
||||
type="text"
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData({...formData, phone: e.target.value})}
|
||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,16 +349,19 @@ function CustomerManagement() {
|
||||
<textarea
|
||||
rows="3"
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData({...formData, notes: e.target.value})}
|
||||
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{formError && <p className="form-error">{formError}</p>}
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowAddModal(false)}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowAddModal(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
保存
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '保存中…' : '保存并进入详情'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { GlobeIcon, RefreshIcon, UserIcon, SearchIcon, CheckIcon } from '../components/Icons'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
import { setPageTitle } from '../branding'
|
||||
|
||||
function fmtDate(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户公海 —— 组织(团队)共享的客户资源池。
|
||||
*
|
||||
* 数据就是 customers 里 pool_status = 'public' 的那批:
|
||||
* 创建人主动把客户丢进来,同组织成员可见、可领取,谁先领归谁。
|
||||
*/
|
||||
function CustomerPool() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const canClaim = hasPermission(user, 'pool.claim')
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle('客户公海')
|
||||
}, [])
|
||||
|
||||
const fetchPool = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data } = await axios.get('/api/customers', { params: { scope: 'pool' } })
|
||||
setRows(Array.isArray(data) ? data : [])
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || '获取公海客户失败')
|
||||
setRows([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchPool() }, [fetchPool])
|
||||
|
||||
/* 领取:乐观更新 —— 先把行从公海拿掉,后台失败再放回原位。
|
||||
409 = 被同事抢先,回滚后顺手刷一次对齐真实状态。 */
|
||||
const claim = async (customer) => {
|
||||
setError('')
|
||||
setNotice('')
|
||||
const index = rows.findIndex(row => row.id === customer.id)
|
||||
setRows(prev => prev.filter(row => row.id !== customer.id))
|
||||
setNotice(`「${customer.company}」已领取,现在归你名下。`)
|
||||
try {
|
||||
await axios.post(`/api/customers/${customer.id}/claim`)
|
||||
} catch (err) {
|
||||
setRows(prev => {
|
||||
if (prev.some(row => row.id === customer.id)) return prev
|
||||
const next = [...prev]
|
||||
next.splice(Math.min(Math.max(index, 0), next.length), 0, customer)
|
||||
return next
|
||||
})
|
||||
setNotice('')
|
||||
setError(err?.response?.data?.error || '领取失败,已恢复原状')
|
||||
if (err?.response?.status === 409) fetchPool()
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = keyword.trim()
|
||||
? rows.filter(row =>
|
||||
[row.company, row.country, row.industry, row.email, row.created_by_name]
|
||||
.filter(Boolean)
|
||||
.some(value => String(value).toLowerCase().includes(keyword.trim().toLowerCase()))
|
||||
)
|
||||
: rows
|
||||
|
||||
return (
|
||||
<div className="page-container pool-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">客户公海</h1>
|
||||
<p className="page-subtitle">
|
||||
与组织(团队)共用的客户资源池:同事主动放出来的客户都躺在下面,同组织成员都能看到,谁先领取就归谁。
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-secondary btn-sm" onClick={fetchPool}>
|
||||
<RefreshIcon size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pool-rules">
|
||||
<div className="pool-rule-card">
|
||||
<span className="pool-rule-index">01</span>
|
||||
<b>主动放出</b>
|
||||
<p>客户默认只有创建人可见。创建人在「我的客户」里点「丢入公海」,这条就进入团队资源池。</p>
|
||||
</div>
|
||||
<div className="pool-rule-card">
|
||||
<span className="pool-rule-index">02</span>
|
||||
<b>团队可见</b>
|
||||
<p>公海客户对同组织成员可见,联系方式与历史跟进都能查,方便快速判断值不值得跟。</p>
|
||||
</div>
|
||||
<div className="pool-rule-card">
|
||||
<span className="pool-rule-index">03</span>
|
||||
<b>领取跟进</b>
|
||||
<p>点「领取」即归属到自己名下并进入「我的客户」,同组织其他人不会再领到。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-title">
|
||||
<GlobeIcon size={15} /> 公海客户
|
||||
<span className="panel-count">{rows.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<span className="toolbar-search">
|
||||
<SearchIcon size={15} />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
placeholder="搜公司 / 国家 / 行业 / 联系方式"
|
||||
aria-label="搜索公海客户"
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{notice && <p className="notice-inline notice-ok"><CheckIcon size={13} /> {notice}</p>}
|
||||
{error && <p className="notice-inline">{error}</p>}
|
||||
|
||||
<div className="table-container table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公司名称</th>
|
||||
<th>国家</th>
|
||||
<th>行业</th>
|
||||
<th>联系方式</th>
|
||||
<th>原归属</th>
|
||||
<th>丢入时间</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<SkeletonTableRows rows={5} widths={[2.2, 0.8, 1, 1.6, 1, 1, 1]} />
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
<GlobeIcon size={28} />
|
||||
<p style={{ margin: '8px 0 0' }}>
|
||||
{rows.length ? '没有匹配的公海客户' : '公海现在是空的 —— 同事把客户丢进来后会出现在这里。'}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map(row => (
|
||||
<tr key={row.id} onClick={() => navigate(`/customers/${row.id}`)}>
|
||||
<td>
|
||||
<strong>{row.company}</strong>
|
||||
{row.pool_note && <div className="muted-note">放出说明:{row.pool_note}</div>}
|
||||
</td>
|
||||
<td>{row.country || '-'}</td>
|
||||
<td>{row.industry || '-'}</td>
|
||||
<td>
|
||||
{row.email && <div>{row.email}</div>}
|
||||
{row.phone && <div>{row.phone}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="owner-cell"><UserIcon size={11} /> {row.owner_name || '未指定'}</span>
|
||||
{row.created_by_name && <div className="muted-note">创建人:{row.created_by_name}</div>}
|
||||
</td>
|
||||
<td className="mono">{fmtDate(row.pool_at)}</td>
|
||||
<td className="col-actions">
|
||||
{canClaim ? (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={event => { event.stopPropagation(); claim(row) }}
|
||||
>
|
||||
领取
|
||||
</button>
|
||||
) : (
|
||||
<span className="muted-note">无领取权限</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerPool
|
||||
@@ -1,21 +1,28 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
ActivityIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
CleanupIcon,
|
||||
ClipboardIcon,
|
||||
ClockIcon,
|
||||
CloseIcon,
|
||||
DollarIcon,
|
||||
FactoryIcon,
|
||||
FlagIcon,
|
||||
InquiryIcon,
|
||||
PackageIcon,
|
||||
PinIcon,
|
||||
PlusCircleIcon,
|
||||
QuoteIcon,
|
||||
SparkIcon,
|
||||
TrashIcon,
|
||||
UsersIcon
|
||||
} from '../components/Icons'
|
||||
import MonthYearWheel from '../components/MonthYearWheel'
|
||||
import { SkeletonPanel } from '../components/Skeleton'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
const WEEKDAY_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
@@ -699,7 +706,6 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
|
||||
<section className="calendar-board dashboard-panel">
|
||||
<div className="dashboard-panel-header calendar-board-header">
|
||||
<div>
|
||||
<span className="section-label">[SCHEDULE]</span>
|
||||
<h2><CalendarIcon size={18} /> 工作日历</h2>
|
||||
<p className="calendar-hint">单击日期选中,再次单击添加日程 · 悬浮日程或节日查看详情</p>
|
||||
</div>
|
||||
@@ -985,10 +991,48 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
|
||||
)
|
||||
}
|
||||
|
||||
const INQUIRY_STATUS_LABELS = {
|
||||
draft: '草稿', quoting: '报价中', quoted: '已报价', won: '已成交', lost: '已流失', closed: '已关闭'
|
||||
}
|
||||
|
||||
const CONTRACT_STATUS_LABELS = {
|
||||
queued: '排队中', parsing: '解析中', pending_review: '待复核', reviewed: '已复核', parse_failed: '解析失败'
|
||||
}
|
||||
|
||||
/** 今日/逾期跟进条目:客户名 + 跟进动作 + 日期,点进去就是客户详情 */
|
||||
function FollowUpRow({ record, overdue }) {
|
||||
return (
|
||||
<li className="followup-row">
|
||||
<span className={`followup-dot${overdue ? ' is-overdue' : ''}`} />
|
||||
<div className="followup-body">
|
||||
<Link className="followup-customer" to={`/customers/${record.customer_id}`}>
|
||||
{record.customer_name || `客户 #${record.customer_id}`}
|
||||
</Link>
|
||||
<span className="followup-action">
|
||||
{record.next_action || `${record.activity_type || '跟进'}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`followup-date${overdue ? ' is-overdue' : ''}`}>
|
||||
{String(record.next_action_date || '').slice(0, 10)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
/* 工作台是跨模块的汇总页,里面的入口必须跟侧栏用同一套口径 ——
|
||||
没有那个模块的权限,入口就不出现在这里(否则侧栏藏了、工作台还漏一个)。 */
|
||||
const canInquiry = hasPermission(user, 'inquiry.view')
|
||||
const canQuote = hasPermission(user, 'quote.view')
|
||||
const canCleanup = hasPermission(user, 'cleanup.view')
|
||||
const canCustomer = hasPermission(user, 'customer.view')
|
||||
const canEditInquiry = hasPermission(user, 'inquiry.edit')
|
||||
const canEditCustomer = hasPermission(user, 'customer.edit')
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/dashboard')
|
||||
@@ -1002,28 +1046,173 @@ function Dashboard() {
|
||||
|
||||
useEffect(() => { loadDashboard() }, [loadDashboard])
|
||||
|
||||
if (loading) return <div className="loading">加载工作台数据…</div>
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-welcome"><div><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
||||
<div className="dashboard-content-grid">
|
||||
<div className="dashboard-primary-column">
|
||||
<section className="dashboard-panel"><SkeletonPanel lines={4} /></section>
|
||||
<section className="dashboard-panel"><SkeletonPanel lines={3} /></section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { stats, recentActivities, todos } = data
|
||||
const {
|
||||
stats, recentActivities, todos,
|
||||
todayFollowUps = [], overdueFollowUps = [], recentInquiries = [],
|
||||
recentQuotes = [], pendingContractJobs = []
|
||||
} = data
|
||||
const completion = todos.length ? Math.max(12, Math.round((todos.filter(todo => todo.status === 'completed').length / todos.length) * 100)) : 0
|
||||
const followUpCount = todayFollowUps.length + overdueFollowUps.length
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
||||
<div className="dashboard-welcome"><div><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
||||
|
||||
<CalendarBoard todos={todos} onTodoCreated={loadDashboard} />
|
||||
|
||||
{/* 跟进卡放日历下面一栏(Wayne 2026-09-15 要求):先看日程,再看今天该催谁 */}
|
||||
{followUpCount > 0 && canCustomer && (
|
||||
<section className="followup-strip">
|
||||
<div className="followup-card is-today">
|
||||
<div className="followup-card-head">
|
||||
<h3><ClockIcon size={15} /> 今日待跟进</h3>
|
||||
<span className="followup-count">{todayFollowUps.length}</span>
|
||||
</div>
|
||||
{todayFollowUps.length === 0
|
||||
? <p className="followup-empty">今天没有安排跟进。</p>
|
||||
: <ul className="followup-list">{todayFollowUps.map(record => <FollowUpRow key={record.id} record={record} />)}</ul>}
|
||||
</div>
|
||||
<div className="followup-card is-overdue">
|
||||
<div className="followup-card-head">
|
||||
<h3><FlagIcon size={15} /> 逾期未跟进</h3>
|
||||
<span className="followup-count">{overdueFollowUps.length}</span>
|
||||
</div>
|
||||
{overdueFollowUps.length === 0
|
||||
? <p className="followup-empty">没有逾期项,节奏很好。</p>
|
||||
: <ul className="followup-list">{overdueFollowUps.map(record => <FollowUpRow key={record.id} record={record} overdue />)}</ul>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="dashboard-content-grid">
|
||||
<div className="dashboard-primary-column">
|
||||
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><span className="section-label">[AI BRIEFING]</span><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{stats.activeDeals || 0}</b> 个进行中商机和 <b>{stats.orders || 0}</b> 笔进行中订单。打开右上角的小皮,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>订单节奏</span></div></div></section>
|
||||
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{followUpCount}</b> 个待跟进客户、<b>{recentInquiries.length}</b> 条最近询盘、<b>{stats.pendingContractJobs || 0}</b> 个待复核合同任务。打开右上角的小皮,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>询盘报价</span><span>订单节奏</span></div></div></section>
|
||||
|
||||
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[ACTIVITY LOG]</span><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div><h2><InquiryIcon size={17} /> 最近询盘</h2></div>
|
||||
{canInquiry && <Link className="panel-link" to="/inquiries">全部询盘</Link>}
|
||||
</div>
|
||||
{recentInquiries.length === 0
|
||||
? <div className="dashboard-empty">还没有询盘记录</div>
|
||||
: (
|
||||
<ul className="compact-list">
|
||||
{recentInquiries.map(inquiry => (
|
||||
<li key={inquiry.id}>
|
||||
{canInquiry ? (
|
||||
<Link className="compact-main" to={`/inquiries/${inquiry.id}`}>
|
||||
<span className="compact-title">{inquiry.title || '未命名询盘'}</span>
|
||||
<span className="compact-sub">
|
||||
<code>{inquiry.inquiry_no}</code>
|
||||
{' · '}
|
||||
{inquiry.customer_name || '未知客户'}
|
||||
{' · '}
|
||||
{inquiry.inquiry_date}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="compact-main">
|
||||
<span className="compact-title">{inquiry.title || '未命名询盘'}</span>
|
||||
<span className="compact-sub">
|
||||
<code>{inquiry.inquiry_no}</code>
|
||||
{' · '}
|
||||
{inquiry.customer_name || '未知客户'}
|
||||
{' · '}
|
||||
{inquiry.inquiry_date}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="compact-tag">{INQUIRY_STATUS_LABELS[inquiry.status] || inquiry.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div><h2><QuoteIcon size={17} /> 最近报价单</h2></div>
|
||||
{canQuote && <Link className="panel-link" to="/quotes">报价单管理</Link>}
|
||||
</div>
|
||||
{recentQuotes.length === 0
|
||||
? <div className="dashboard-empty">还没有客户报价单</div>
|
||||
: (
|
||||
<ul className="compact-list">
|
||||
{recentQuotes.map(quote => (
|
||||
<li key={quote.id}>
|
||||
<span className="compact-main">
|
||||
<span className="compact-title"><code>{quote.quote_no}</code></span>
|
||||
<span className="compact-sub">
|
||||
{quote.customer_name || '未知客户'}
|
||||
{' · '}
|
||||
{quote.quote_date}
|
||||
{quote.valid_until ? ` · 有效期至 ${quote.valid_until}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="compact-tag">{quote.currency || ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
|
||||
</div>
|
||||
|
||||
<aside className="dashboard-secondary-column">
|
||||
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><span className="section-label">[SYSTEM PULSE]</span><h2>业务概览</h2></div><span className="panel-period">本月</span></div><StatLine icon={<FactoryIcon size={17} />} label="供应商" value={stats.suppliers || 0} /><StatLine icon={<PackageIcon size={17} />} label="产品 SKU" value={stats.products || 0} /><StatLine icon={<UsersIcon size={17} />} label="客户" value={stats.customers || 0} /><StatLine icon={<ClipboardIcon size={17} />} label="进行中订单" value={stats.orders || 0} /><StatLine icon={<DollarIcon size={17} />} label="进行中商机" value={stats.activeDeals || 0} /></section>
|
||||
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK HEALTH]</span><h2>任务完成情况</h2></div><span className="panel-period">当前</span></div><div className="completion-ring" style={{ '--progress': `${completion}%` }}><div><strong>{completion}%</strong><span>完成率</span></div></div><div className="completion-legend"><span><i className="dot done" />已完成</span><span><i className="dot pending" />待处理</span></div></section>
|
||||
<section className="dashboard-panel quick-panel"><div className="dashboard-panel-header"><div><span className="section-label">[QUICK ACCESS]</span><h2>快捷入口</h2></div></div><button><UsersIcon size={16} /> 新增客户</button><button><PackageIcon size={16} /> 添加产品</button><button><ClipboardIcon size={16} /> 新建订单</button></section>
|
||||
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><h2>业务概览</h2></div><span className="panel-period">本月</span></div><StatLine icon={<FactoryIcon size={17} />} label="供应商" value={stats.suppliers || 0} /><StatLine icon={<PackageIcon size={17} />} label="产品 SKU" value={stats.products || 0} /><StatLine icon={<UsersIcon size={17} />} label="客户" value={stats.customers || 0} /><StatLine icon={<ClipboardIcon size={17} />} label="进行中订单" value={stats.orders || 0} /><StatLine icon={<InquiryIcon size={17} />} label="待跟进" value={followUpCount} /><StatLine icon={<CleanupIcon size={17} />} label="待复核合同" value={stats.pendingContractJobs || 0} /></section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div><h2><CleanupIcon size={17} /> 待审核合同</h2></div>
|
||||
{canCleanup && <Link className="panel-link" to="/contract-cleanup">去复核</Link>}
|
||||
</div>
|
||||
{pendingContractJobs.length === 0
|
||||
? <div className="dashboard-empty">没有待复核的导入任务</div>
|
||||
: (
|
||||
<ul className="compact-list">
|
||||
{pendingContractJobs.map(job => (
|
||||
<li key={job.id}>
|
||||
{canCleanup ? (
|
||||
<Link className="compact-main" to="/contract-cleanup">
|
||||
<span className="compact-title"><code>{job.job_no}</code></span>
|
||||
<span className="compact-sub">
|
||||
{job.file_count} 个文件 · {new Date(job.created_at).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="compact-main">
|
||||
<span className="compact-title"><code>{job.job_no}</code></span>
|
||||
<span className="compact-sub">
|
||||
{job.file_count} 个文件 · {new Date(job.created_at).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="compact-tag">{CONTRACT_STATUS_LABELS[job.status] || job.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><h2>任务完成情况</h2></div><span className="panel-period">当前</span></div><div className="completion-ring" style={{ '--progress': `${completion}%` }}><div><strong>{completion}%</strong><span>完成率</span></div></div><div className="completion-legend"><span><i className="dot done" />已完成</span><span><i className="dot pending" />待处理</span></div></section>
|
||||
<section className="dashboard-panel quick-panel"><div className="dashboard-panel-header"><div><h2>快捷入口</h2></div></div>{canEditInquiry && <Link to="/inquiries"><InquiryIcon size={16} /> 新建询盘</Link>}{canEditCustomer && <Link to="/customers"><UsersIcon size={16} /> 新增客户</Link>}{canCleanup && <Link to="/contract-cleanup"><CleanupIcon size={16} /> 导入合同</Link>}</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { RefreshIcon } from '../components/Icons'
|
||||
|
||||
/**
|
||||
* SKU 功能嵌入页(整合过渡期方案)。
|
||||
*
|
||||
* 合并策略是「先嵌后重写」:SKU 的能力先以嵌入方式跑通,
|
||||
* 之后再逐步重写为原生 React 页面。
|
||||
*
|
||||
* 现在嵌入的是独立的 sku-manager 服务(3300);等后端合并(P1/P2)完成后,
|
||||
* 把 SKU_ORIGIN 换成同源子路径(如 /sku)即可,页面组件本身不用动。
|
||||
*
|
||||
* 外观一体:跨源 iframe 读不到宿主的 CSS 变量,所以由宿主把当前主题下
|
||||
* 这些变量的实际取值读出来 postMessage 过去,嵌入页照单贴到自己对应的
|
||||
* 变量上(嵌入页侧不含任何宿主变量名,见 sku-manager/public/embed.css)。
|
||||
*/
|
||||
const SKU_ORIGIN = import.meta.env.VITE_SKU_ORIGIN || 'http://127.0.0.1:3300'
|
||||
|
||||
/**
|
||||
* 设计令牌桥:左边是嵌入页的 CSS 变量名,右边是宿主这边的取值来源。
|
||||
* 只有这一处维护映射 —— 嵌入页负责结构,宿主负责配色与字体。
|
||||
*/
|
||||
const TOKEN_BRIDGE = {
|
||||
'--bg': '--bg-primary', // 卡片底色 = 外壳底色 → 嵌入页"消失"
|
||||
'--panel': '--surface', // 卡片、输入框默认底
|
||||
'--panel-2': '--surface-deep', // 比卡片再深一档:输入框、拖入区
|
||||
'--raised': '--surface-hover', // 悬浮、拖拽经过
|
||||
'--line': '--border',
|
||||
'--line-soft': '--border-light',
|
||||
'--fg': '--text-primary',
|
||||
'--fg-dim': '--text-secondary',
|
||||
'--fg-mute': '--text-tertiary',
|
||||
'--accent': '--primary',
|
||||
'--accent-ink': '--primary',
|
||||
'--accent-line': '--primary-border',
|
||||
'--accent-glow': '--accent-glow',
|
||||
'--accent-fill': '--primary-bg', // 输入框聚焦时的实底
|
||||
'--on-accent': '--bg-primary', // 强调色上的文字:取宿主底色,两种主题都自动是反色
|
||||
'--font-display': '--font-body',
|
||||
'--font-body': '--font-body',
|
||||
'--font-mono': '--font-mono',
|
||||
'--r-sm': '--radius-sm',
|
||||
'--r-md': '--radius-md'
|
||||
}
|
||||
|
||||
const VIEW_META = {
|
||||
search: {
|
||||
title: '产品管理',
|
||||
sub: '按货号、品名、规格、材质或参数检索产品与采购价'
|
||||
},
|
||||
suppliers: {
|
||||
title: '供应商管理',
|
||||
sub: '供应商档案、联系方式、工厂所在地与主营类目'
|
||||
},
|
||||
import: {
|
||||
title: '合同导入',
|
||||
sub: '批量导入采购合同,自动解析价格、产品与参数'
|
||||
}
|
||||
}
|
||||
|
||||
function EmbeddedSku({ view = 'search', theme = 'dark' }) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const frameRef = useRef(null)
|
||||
const meta = VIEW_META[view] || VIEW_META.search
|
||||
|
||||
/* src 里的 theme 只当"首帧用哪个主题"的初始值:钉死成首次渲染时的值。
|
||||
否则换主题时 React 会改 src,iframe 整页重载白闪一下。 */
|
||||
const initialTheme = useRef(theme).current
|
||||
|
||||
const pushTheme = useCallback(() => {
|
||||
const frame = frameRef.current
|
||||
if (!frame?.contentWindow) return
|
||||
const computed = window.getComputedStyle(document.documentElement)
|
||||
const vars = {}
|
||||
Object.entries(TOKEN_BRIDGE).forEach(([skuVar, hostVar]) => {
|
||||
const value = computed.getPropertyValue(hostVar).trim()
|
||||
/* 宿主改了变量名而这里没跟上时,嵌入页会悄悄回落到自己的旧配色 ——
|
||||
必须喊出来,否则要对着截图猜半天。 */
|
||||
if (!value) console.warn(`[embed] 宿主找不到变量 ${hostVar},嵌入页的 ${skuVar} 将回落默认值`)
|
||||
vars[skuVar] = value
|
||||
})
|
||||
frame.contentWindow.postMessage({ type: 'happy:embed', theme, vars }, '*')
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return undefined
|
||||
/* 放到下一个宏任务:App 是在自己的 effect 里才把 data-theme 写到 <html> 上,
|
||||
而子组件的 effect 先于父组件执行,此刻读到的还是上一个主题的令牌。 */
|
||||
const id = window.setTimeout(pushTheme, 0)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [loaded, pushTheme])
|
||||
|
||||
const reload = () => {
|
||||
setLoaded(false)
|
||||
frameRef.current?.contentWindow?.location.reload()
|
||||
}
|
||||
|
||||
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}&theme=${initialTheme}`
|
||||
|
||||
return (
|
||||
<div className="page-container embed-shell">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{meta.title}</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginTop: '6px' }}>
|
||||
{meta.sub}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="embed-refresh" onClick={reload} title="重新加载" aria-label="重新加载">
|
||||
<RefreshIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="embed-frame">
|
||||
{!loaded && (
|
||||
<div className="loading" style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||
<div className="spinner"></div>
|
||||
<span style={{ marginLeft: '12px' }}>正在加载{meta.title}…</span>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title={meta.title}
|
||||
src={src}
|
||||
onLoad={() => setLoaded(true)}
|
||||
allow="clipboard-write"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 0,
|
||||
display: 'block',
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity .18s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmbeddedSku
|
||||
@@ -0,0 +1,601 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import Drawer from '../components/Drawer'
|
||||
import SkuPicker from '../components/SkuPicker'
|
||||
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
|
||||
import {
|
||||
PlusCircleIcon, PencilIcon, TrashIcon, PackageIcon, ClockIcon,
|
||||
InquiryIcon, DollarIcon, RefreshIcon
|
||||
} from '../components/Icons'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
quoting: '报价中',
|
||||
quoted: '已报价',
|
||||
won: '已成交',
|
||||
lost: '已流失',
|
||||
closed: '已关闭'
|
||||
}
|
||||
const STATUS_TONE = {
|
||||
draft: 'mute',
|
||||
quoting: 'warn',
|
||||
quoted: 'ok',
|
||||
won: 'ok',
|
||||
lost: 'danger',
|
||||
closed: 'mute'
|
||||
}
|
||||
|
||||
const CURRENCIES = ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD']
|
||||
|
||||
/**
|
||||
* 报价合计。
|
||||
* 口径说明:inquiry_items.currency 存的是「采购币种」,而 EXW 是拿采购价除以汇率算出来的,
|
||||
* 所以 EXW / 小计 / 合计都落在「报价币种」上,也就是 inquiry.currency。
|
||||
* 这里不做跨币种换算 —— 一张询盘的报价币种是固定的(汇率那一栏的分母就是它)。
|
||||
*/
|
||||
function summarize(items) {
|
||||
return items.reduce((sum, item) => sum + Number(item.exw || 0) * Number(item.quantity || 0), 0)
|
||||
}
|
||||
|
||||
function money(value, digits = 2) {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return '—'
|
||||
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
|
||||
}
|
||||
|
||||
/** 包装快照读出来做「一行摘要」,让报价行不用展开就知道箱规 */
|
||||
function packagingBrief(snapshot) {
|
||||
if (!snapshot) return null
|
||||
let data = null
|
||||
try { data = JSON.parse(snapshot) } catch { return null }
|
||||
if (!data) return null
|
||||
const parts = []
|
||||
if (data.carton_qty) parts.push(`${data.carton_qty} pcs/箱`)
|
||||
if (data.carton_length && data.carton_width && data.carton_height) {
|
||||
parts.push(`${data.carton_length}×${data.carton_width}×${data.carton_height} cm`)
|
||||
}
|
||||
if (data.carton_weight) parts.push(`毛重 ${data.carton_weight} kg`)
|
||||
if (data.packaging_material) parts.push(data.packaging_material)
|
||||
return parts.length ? parts : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 询盘明细编辑器。
|
||||
* 两种输入方式:
|
||||
* 自动 —— 填加价率,EXW 由「采购价 ÷ 汇率 ×(1+加价率)」算出(这是报价的标准姿势)
|
||||
* 手动 —— 直接给 EXW,反算加价率(客户先给目标价时用)
|
||||
* 两者互斥,切换时保留当前能推导的量,避免把用户已经填好的数字清掉。
|
||||
*/
|
||||
function ItemEditor({ inquiry, item, existingSkus = [], onClose, onSaved, canEdit }) {
|
||||
const isNew = !item
|
||||
const [skuChoice, setSkuChoice] = useState(isNew ? null : { sku: item.sku, display_sku: item.sku, source: 'existing' })
|
||||
const [pickerOpen, setPickerOpen] = useState(isNew)
|
||||
const [mode, setMode] = useState('auto')
|
||||
const [form, setForm] = useState(() => ({
|
||||
purchase_price: item ? String(item.purchase_price ?? '') : '',
|
||||
// 采购币种默认取人民币 —— 供应商报价绝大多数是 CNY,拿询盘的报价币种当默认值会把人带偏
|
||||
currency: item?.currency || 'CNY',
|
||||
exchange_rate: item ? String(item.exchange_rate ?? '') : '',
|
||||
markup_rate: item ? String(((Number(item.markup_rate) || 0) * 100).toFixed(2).replace(/\.?0+$/, '')) : '25',
|
||||
manual_exw: '',
|
||||
quantity: item ? String(item.quantity ?? '') : '1'
|
||||
}))
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const purchasePrice = Number(form.purchase_price)
|
||||
const exchangeRate = Number(form.exchange_rate)
|
||||
const markupRate = Number(form.markup_rate) / 100
|
||||
const manualExw = Number(form.manual_exw)
|
||||
|
||||
const base = Number.isFinite(purchasePrice) && Number.isFinite(exchangeRate) && exchangeRate > 0
|
||||
? purchasePrice / exchangeRate
|
||||
: null
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (base === null) return null
|
||||
if (mode === 'manual') {
|
||||
if (!Number.isFinite(manualExw) || manualExw <= 0) return null
|
||||
return { exw: manualExw, markup: manualExw / base - 1 }
|
||||
}
|
||||
if (!Number.isFinite(markupRate) || markupRate < -1) return null
|
||||
return { exw: base * (1 + markupRate), markup: markupRate }
|
||||
}, [mode, base, manualExw, markupRate])
|
||||
|
||||
const pickSku = async (choice) => {
|
||||
setSkuChoice(choice)
|
||||
setPickerOpen(false)
|
||||
setError('')
|
||||
// 命中 SKU 服务时返回的是完整货号,顺手把采购价/币种带出来,省一次手输
|
||||
if (choice.source === 'manual') return
|
||||
setForm(current => ({
|
||||
...current,
|
||||
purchase_price: choice.price === null || choice.price === undefined ? current.purchase_price : String(choice.price),
|
||||
currency: choice.currency || current.currency
|
||||
}))
|
||||
}
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!skuChoice?.sku) { setError('请先选择货号'); return }
|
||||
const quantity = Number(form.quantity)
|
||||
if (!Number.isInteger(quantity) || quantity <= 0) { setError('数量必须是大于 0 的整数'); return }
|
||||
if (!Number.isFinite(purchasePrice) || purchasePrice <= 0) { setError('采购价必须是大于 0 的数字'); return }
|
||||
if (!Number.isFinite(exchangeRate) || exchangeRate <= 0) { setError('汇率必须是大于 0 的数字'); return }
|
||||
|
||||
const payload = {
|
||||
sku: skuChoice.sku,
|
||||
purchase_price: purchasePrice,
|
||||
currency: form.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
quantity
|
||||
}
|
||||
if (mode === 'manual') {
|
||||
if (!Number.isFinite(manualExw) || manualExw <= 0) { setError('手动 EXW 必须是大于 0 的数字'); return }
|
||||
payload.manual_exw = manualExw
|
||||
} else {
|
||||
payload.markup_rate = markupRate
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
if (isNew) await axios.post(`/api/inquiries/${inquiry.id}/items`, payload)
|
||||
else await axios.patch(`/api/inquiry-items/${item.id}`, payload)
|
||||
onSaved()
|
||||
} catch (saveError) {
|
||||
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const brief = item ? packagingBrief(item.packaging_snapshot) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
wide
|
||||
title={isNew ? '添加报价明细' : `编辑明细 · ${item.sku}`}
|
||||
sub={isNew ? `询盘 ${inquiry?.inquiry_no || ''}` : item.product_name}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
|
||||
{canEdit && (
|
||||
<button type="submit" form="inquiry-item-form" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '保存中…' : '保存明细'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<form id="inquiry-item-form" onSubmit={submit}>
|
||||
<div className="panel-title">货号</div>
|
||||
<div className="sku-chosen">
|
||||
{skuChoice ? (
|
||||
<>
|
||||
<div className="sku-chosen-main">
|
||||
<SkuThumb sku={skuChoice.sku} filename={skuChoice.thumb} size={36} />
|
||||
<b>{skuChoice.display_sku || skuChoice.sku}</b>
|
||||
<span>{skuChoice.name || skuChoice.spec || (skuChoice.source === 'manual' ? '手工输入货号' : '')}</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPickerOpen(true)}>
|
||||
更换货号
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="muted-note">还没有选择货号。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel-title">报价参数</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>采购价(含税单价)</label>
|
||||
<input
|
||||
type="number" min="0" step="0.01" inputMode="decimal"
|
||||
value={form.purchase_price}
|
||||
onChange={event => setForm({ ...form, purchase_price: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>采购币种</label>
|
||||
<select value={form.currency} onChange={event => setForm({ ...form, currency: event.target.value })}>
|
||||
{CURRENCIES.map(currency => <option key={currency} value={currency}>{currency}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>汇率(1 {inquiry?.currency || '报价币'} = ? {form.currency})</label>
|
||||
<input
|
||||
type="number" min="0" step="0.0001" inputMode="decimal"
|
||||
value={form.exchange_rate}
|
||||
placeholder="例如 6.7"
|
||||
onChange={event => setForm({ ...form, exchange_rate: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>数量</label>
|
||||
<input
|
||||
type="number" min="1" step="1" inputMode="numeric"
|
||||
value={form.quantity}
|
||||
onChange={event => setForm({ ...form, quantity: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-title">定价方式</div>
|
||||
<div className="mode-switch" role="tablist">
|
||||
<button
|
||||
type="button" role="tab" aria-selected={mode === 'auto'}
|
||||
className={mode === 'auto' ? 'is-active' : ''}
|
||||
onClick={() => setMode('auto')}
|
||||
>
|
||||
按加价率算 EXW
|
||||
</button>
|
||||
<button
|
||||
type="button" role="tab" aria-selected={mode === 'manual'}
|
||||
className={mode === 'manual' ? 'is-active' : ''}
|
||||
onClick={() => setMode('manual')}
|
||||
>
|
||||
手填 EXW 反算加价率
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
{mode === 'auto' ? (
|
||||
<div className="form-group">
|
||||
<label>加价率</label>
|
||||
<span className="pkg-input">
|
||||
<input
|
||||
type="number" step="0.1" inputMode="decimal"
|
||||
value={form.markup_rate}
|
||||
placeholder="例如 25"
|
||||
onChange={event => setForm({ ...form, markup_rate: event.target.value })}
|
||||
/>
|
||||
<em>%</em>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label>目标 EXW 单价</label>
|
||||
<input
|
||||
type="number" min="0" step="0.01" inputMode="decimal"
|
||||
value={form.manual_exw}
|
||||
placeholder="客户给的目标价"
|
||||
onChange={event => setForm({ ...form, manual_exw: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>{mode === 'auto' ? '加价系数' : '反算加价系数'}</label>
|
||||
<div className="readout">
|
||||
{preview ? (preview.markup + 1).toFixed(4) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="exw-preview">
|
||||
<div className="exw-preview-formula">
|
||||
EXW = 采购价 ÷ 汇率 ×(1 + 加价率)
|
||||
</div>
|
||||
<div className="exw-preview-main">
|
||||
<span>
|
||||
EXW 单价
|
||||
<em className="exw-preview-currency">报价币种 {inquiry?.currency || '—'}</em>
|
||||
</span>
|
||||
<strong>{preview ? `${money(preview.exw, 4)} ${inquiry?.currency || ''}` : '—'}</strong>
|
||||
</div>
|
||||
<div className="exw-preview-sub">
|
||||
{base === null ? (
|
||||
<span>填好采购价和汇率后自动计算。</span>
|
||||
) : (
|
||||
<>
|
||||
<span>采购价折报价币 <b>{money(base, 4)}</b></span>
|
||||
<span>加价率 <b>{preview ? `${(preview.markup * 100).toFixed(2)}%` : '—'}</b></span>
|
||||
<span>小计 <b>{preview ? `${money(preview.exw * Number(form.quantity || 0), 2)} ${inquiry?.currency || ''}` : '—'}</b></span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{brief && (
|
||||
<>
|
||||
<div className="panel-title">包装(本次报价时的快照)</div>
|
||||
<ul className="snapshot-list">
|
||||
{brief.map(text => <li key={text}><PackageIcon size={13} /> {text}</li>)}
|
||||
</ul>
|
||||
<p className="muted-note">快照在报价那一刻定格,之后产品包装信息再改也不会动这行报价。</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</form>
|
||||
</Drawer>
|
||||
|
||||
{pickerOpen && (
|
||||
<div className="modal-overlay" onClick={() => (isNew ? onClose() : setPickerOpen(false))}>
|
||||
<div className="modal-content sku-picker-modal" onClick={event => event.stopPropagation()}>
|
||||
<SkuPicker
|
||||
excludeSkus={existingSkus}
|
||||
onClose={() => (isNew ? onClose() : setPickerOpen(false))}
|
||||
onPick={pickSku}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InquiryDetail() {
|
||||
const { id } = useParams()
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'inquiry.edit')
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [editor, setEditor] = useState(null) // { item } 或 { item: null } 表示新增
|
||||
const [expandedPackaging, setExpandedPackaging] = useState(null)
|
||||
|
||||
/* 明细里的货号只在本地库里,图片得按货号批量问 SKU 服务要 */
|
||||
const itemThumbs = useSkuThumbs((data?.items || []).map(row => row.sku))
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await axios.get(`/api/inquiries/${id}`)
|
||||
setData(response.data)
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || '读取询盘失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const updateStatus = async (status) => {
|
||||
try {
|
||||
await axios.patch(`/api/inquiries/${id}`, { status })
|
||||
await load()
|
||||
} catch (statusError) {
|
||||
setError(statusError?.response?.data?.error || '更新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = async (item) => {
|
||||
if (!window.confirm(`删除明细「${item.product_name || item.sku}」?删除后不可恢复。`)) return
|
||||
try {
|
||||
await axios.delete(`/api/inquiry-items/${item.id}`)
|
||||
await load()
|
||||
} catch (removeError) {
|
||||
setError(removeError?.response?.data?.error || '删除失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const inquiry = data?.inquiry
|
||||
const items = data?.items || []
|
||||
const totals = useMemo(() => summarize(items), [items])
|
||||
|
||||
if (loading) return <div className="loading">加载询盘…</div>
|
||||
if (!inquiry) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<p className="notice-inline">{error || '询盘不存在'}</p>
|
||||
<Link className="btn btn-secondary btn-sm" to="/inquiries">返回询盘列表</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/inquiries">询盘管理</Link>
|
||||
<span>/</span>
|
||||
<span className="mono">{inquiry.inquiry_no}</span>
|
||||
</div>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{inquiry.title || '未命名询盘'}</h1>
|
||||
<p className="page-sub">
|
||||
<Link to={`/customers/${inquiry.customer_id}`}>{inquiry.customer_name || '未知客户'}</Link>
|
||||
{' · '}
|
||||
{inquiry.inquiry_date}
|
||||
{' · '}
|
||||
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
|
||||
{STATUS_LABELS[inquiry.status] || inquiry.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
|
||||
<RefreshIcon size={14} /> 刷新
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setEditor({ item: null })}>
|
||||
<PlusCircleIcon size={15} /> 添加货号
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-rail inquiry-rail">
|
||||
<div className="stat-line">
|
||||
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
|
||||
<span>报价明细</span><strong>{items.length}</strong>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-line-icon"><PackageIcon size={17} /></span>
|
||||
<span>总数量</span>
|
||||
<strong>{items.reduce((sum, item) => sum + Number(item.quantity || 0), 0).toLocaleString('zh-CN')}</strong>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-line-icon"><DollarIcon size={17} /></span>
|
||||
<span>报价合计</span>
|
||||
<strong>
|
||||
{items.length ? `${money(totals, 2)} ${inquiry.currency || ''}` : '—'}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="notice-inline">{error}</p>}
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><DollarIcon size={17} /> 报价明细</h2>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{canEdit ? (
|
||||
<label className="inline-field">
|
||||
<span>询盘状态</span>
|
||||
<select value={inquiry.status} onChange={event => updateStatus(event.target.value)}>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
|
||||
{STATUS_LABELS[inquiry.status] || inquiry.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">这次询盘还没有报价行</p>
|
||||
<p className="empty-state-hint">点右上角「添加货号」,从已有 SKU 里选,或直接敲完整货号。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table quote-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>货号</th>
|
||||
<th>产品 / 供应商</th>
|
||||
<th className="num">采购价</th>
|
||||
<th className="num">汇率</th>
|
||||
<th className="num">加价率</th>
|
||||
<th className="num">加价系数</th>
|
||||
<th className="num">EXW<span className="th-unit">{inquiry.currency}</span></th>
|
||||
<th className="num">数量</th>
|
||||
<th className="num">小计<span className="th-unit">{inquiry.currency}</span></th>
|
||||
<th>包装</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => {
|
||||
const brief = packagingBrief(item.packaging_snapshot)
|
||||
const open = expandedPackaging === item.id
|
||||
const subtotal = Number(item.exw || 0) * Number(item.quantity || 0)
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<tr>
|
||||
<td className="mono bold">
|
||||
<span className="sku-cell">
|
||||
<SkuThumb sku={item.sku} filename={itemThumbs[item.sku]} size={30} />
|
||||
{item.sku}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>{item.product_name || '—'}</div>
|
||||
<div className="cell-sub">{item.supplier_name || '未指派供应商'}</div>
|
||||
</td>
|
||||
<td className="num">{money(item.purchase_price, 2)} <span className="unit">{item.currency}</span></td>
|
||||
<td className="num">{money(item.exchange_rate, 4)}</td>
|
||||
<td className="num">{(Number(item.markup_rate || 0) * 100).toFixed(2)}%</td>
|
||||
<td className="num">{money(item.markup_factor, 4)}</td>
|
||||
<td className="num strong-cell">{money(item.exw, 4)}</td>
|
||||
<td className="num">{Number(item.quantity || 0).toLocaleString('zh-CN')}</td>
|
||||
<td className="num">{money(subtotal, 2)}</td>
|
||||
<td>
|
||||
{brief ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`packaging-chip${open ? ' is-open' : ''}`}
|
||||
onClick={() => setExpandedPackaging(open ? null : item.id)}
|
||||
title="展开包装快照"
|
||||
>
|
||||
<PackageIcon size={13} /> {brief[0]}
|
||||
</button>
|
||||
) : (
|
||||
<span className="muted-note">未维护</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
{canEdit && (
|
||||
<button type="button" className="icon-btn" title="编辑明细" onClick={() => setEditor({ item })}>
|
||||
<PencilIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button type="button" className="icon-btn" title="删除明细" onClick={() => removeItem(item)}>
|
||||
<TrashIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{open && brief && (
|
||||
<tr className="snapshot-row">
|
||||
<td colSpan={11}>
|
||||
<div className="snapshot-detail">
|
||||
<span className="snapshot-detail-title"><PackageIcon size={13} /> 报价时的包装快照</span>
|
||||
<ul className="snapshot-list">
|
||||
{brief.map(text => <li key={text}>{text}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><ClockIcon size={17} /> 跟进记录</h2>
|
||||
</div>
|
||||
<Link className="btn btn-secondary btn-sm" to={`/customers/${inquiry.customer_id}`}>
|
||||
去客户详情记录跟进
|
||||
</Link>
|
||||
</div>
|
||||
<p className="muted-note">
|
||||
跟进记录统一挂在客户身上,记录时可以在「关联询盘」里选中本次询盘({inquiry.inquiry_no}),
|
||||
这样客户时间线和工作台待跟进都能对上。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{editor && (
|
||||
<ItemEditor
|
||||
inquiry={inquiry}
|
||||
item={editor.item}
|
||||
existingSkus={items.filter(row => row.id !== editor.item?.id).map(row => row.sku)}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={() => { setEditor(null); load() }}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InquiryDetail
|
||||
@@ -0,0 +1,357 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { InquiryIcon, PlusCircleIcon, SearchIcon, RefreshIcon, UsersIcon } from '../components/Icons'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
quoting: '报价中',
|
||||
quoted: '已报价',
|
||||
won: '已成交',
|
||||
lost: '已流失',
|
||||
closed: '已关闭'
|
||||
}
|
||||
const STATUS_TONE = {
|
||||
draft: 'mute',
|
||||
quoting: 'warn',
|
||||
quoted: 'ok',
|
||||
won: 'ok',
|
||||
lost: 'danger',
|
||||
closed: 'mute'
|
||||
}
|
||||
const SOURCES = ['展会', '邮件开发', '老客户复购', '阿里国际站', '客户主动询价', '其他']
|
||||
|
||||
function InquiryManagement() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'inquiry.edit')
|
||||
const [inquiries, setInquiries] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
customer_id: '', title: '', inquiry_date: new Date().toISOString().slice(0, 10),
|
||||
currency: 'USD', source: SOURCES[0], notes: ''
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [inquiryResponse, customerResponse] = await Promise.all([
|
||||
axios.get('/api/inquiries'),
|
||||
axios.get('/api/customers')
|
||||
])
|
||||
setInquiries(Array.isArray(inquiryResponse.data) ? inquiryResponse.data : [])
|
||||
setCustomers(Array.isArray(customerResponse.data) ? customerResponse.data : [])
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || '读取询盘列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase()
|
||||
return inquiries.filter(inquiry => {
|
||||
if (statusFilter && inquiry.status !== statusFilter) return false
|
||||
if (!term) return true
|
||||
return [inquiry.inquiry_no, inquiry.title, inquiry.customer_name]
|
||||
.filter(Boolean)
|
||||
.some(value => String(value).toLowerCase().includes(term))
|
||||
})
|
||||
}, [inquiries, keyword, statusFilter])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result = { total: inquiries.length }
|
||||
inquiries.forEach(inquiry => { result[inquiry.status] = (result[inquiry.status] || 0) + 1 })
|
||||
return result
|
||||
}, [inquiries])
|
||||
|
||||
const openAdd = () => {
|
||||
setForm({
|
||||
customer_id: customers[0]?.id ? String(customers[0].id) : '',
|
||||
title: '', inquiry_date: new Date().toISOString().slice(0, 10),
|
||||
currency: 'USD', source: SOURCES[0], notes: ''
|
||||
})
|
||||
setFormError('')
|
||||
setShowAdd(true)
|
||||
}
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!form.customer_id) { setFormError('请选择客户'); return }
|
||||
if (!customers.length) { setFormError('还没有客户,请先到「客户管理」新增一个客户'); return }
|
||||
setSaving(true)
|
||||
setFormError('')
|
||||
try {
|
||||
const response = await axios.post('/api/inquiries', {
|
||||
customer_id: Number(form.customer_id),
|
||||
title: form.title.trim() || null,
|
||||
inquiry_date: form.inquiry_date,
|
||||
currency: form.currency,
|
||||
source: form.source || null,
|
||||
notes: form.notes.trim() || null
|
||||
})
|
||||
const created = response.data?.inquiry
|
||||
setShowAdd(false)
|
||||
if (created?.id) navigate(`/inquiries/${created.id}`)
|
||||
else load()
|
||||
} catch (submitError) {
|
||||
setFormError(submitError?.response?.data?.error || '创建询盘失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">询盘管理</h1>
|
||||
<p className="page-sub">客户的第一封询价、绑定的货号、最终的 EXW 报价,都在这里串起来。</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
|
||||
<RefreshIcon size={14} /> 刷新
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
|
||||
<PlusCircleIcon size={15} /> 新建询盘
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<span className="toolbar-search">
|
||||
<SearchIcon size={15} />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
placeholder="搜询盘号 / 标题 / 客户"
|
||||
aria-label="搜索询盘"
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
<div className="chip-filters">
|
||||
<button
|
||||
type="button"
|
||||
className={`chip-filter${statusFilter === '' ? ' is-active' : ''}`}
|
||||
onClick={() => setStatusFilter('')}
|
||||
>
|
||||
全部 <b>{counts.total || 0}</b>
|
||||
</button>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
counts[value] ? (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
className={`chip-filter${statusFilter === value ? ' is-active' : ''}`}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
>
|
||||
{label} <b>{counts[value]}</b>
|
||||
</button>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="notice-inline">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>询盘号</th>
|
||||
<th>客户</th>
|
||||
<th>标题</th>
|
||||
<th>询盘日期</th>
|
||||
<th className="num">货号数</th>
|
||||
<th>报价币种</th>
|
||||
<th>来源</th>
|
||||
<th>状态</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<SkeletonTableRows rows={6} widths={[1.2, 1.2, 1.8, 1, 0.7, 0.9, 0.9, 0.9, 1]} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="dashboard-panel">
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">
|
||||
{inquiries.length ? '没有符合条件的询盘' : '还没有任何询盘'}
|
||||
</p>
|
||||
<p className="empty-state-hint">
|
||||
{inquiries.length
|
||||
? '换个关键词或状态试试。'
|
||||
: '客户来了第一封询价时,先建一条询盘,再往里面挂货号。'}
|
||||
</p>
|
||||
{!inquiries.length && canEdit && (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd} style={{ marginTop: 14 }}>
|
||||
<PlusCircleIcon size={15} /> 新建询盘
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>询盘号</th>
|
||||
<th>客户</th>
|
||||
<th>标题</th>
|
||||
<th>询盘日期</th>
|
||||
<th className="num">货号数</th>
|
||||
<th>报价币种</th>
|
||||
<th>来源</th>
|
||||
<th>状态</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(inquiry => (
|
||||
<tr key={inquiry.id} onClick={() => navigate(`/inquiries/${inquiry.id}`)}>
|
||||
<td className="mono bold">{inquiry.inquiry_no}</td>
|
||||
<td>{inquiry.customer_name || '—'}</td>
|
||||
<td>{inquiry.title || <span className="muted-note">未命名</span>}</td>
|
||||
<td className="mono">{inquiry.inquiry_date}</td>
|
||||
<td className="num">{inquiry.item_count ?? 0}</td>
|
||||
<td>{inquiry.currency || '—'}</td>
|
||||
<td>{inquiry.source || '—'}</td>
|
||||
<td>
|
||||
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
|
||||
{STATUS_LABELS[inquiry.status] || inquiry.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
<Link
|
||||
className="btn-link"
|
||||
to={`/inquiries/${inquiry.id}`}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
查看报价
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<div className="modal-overlay" onClick={() => setShowAdd(false)}>
|
||||
<div className="modal-content" onClick={event => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2><InquiryIcon size={18} /> 新建询盘</h2>
|
||||
<button type="button" className="icon-btn" onClick={() => setShowAdd(false)} aria-label="关闭">×</button>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
{customers.length === 0 && (
|
||||
<p className="notice-inline">
|
||||
还没有客户档案。先去
|
||||
<Link to="/customers" onClick={() => setShowAdd(false)}> 客户管理 </Link>
|
||||
建一个客户,询盘必须挂在客户身上。
|
||||
</p>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>客户 *</label>
|
||||
<select
|
||||
value={form.customer_id}
|
||||
onChange={event => setForm({ ...form, customer_id: event.target.value })}
|
||||
>
|
||||
<option value="">请选择客户</option>
|
||||
{customers.map(customer => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.company}{customer.country ? ` · ${customer.country}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>询盘标题</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
placeholder="例如:2026 春季手动工具首轮询价"
|
||||
onChange={event => setForm({ ...form, title: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>询盘日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.inquiry_date}
|
||||
onChange={event => setForm({ ...form, inquiry_date: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>报价币种</label>
|
||||
<select
|
||||
value={form.currency}
|
||||
onChange={event => setForm({ ...form, currency: event.target.value })}
|
||||
>
|
||||
{['USD', 'EUR', 'CNY', 'GBP', 'JPY', 'AUD'].map(currency => (
|
||||
<option key={currency} value={currency}>{currency}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>来源渠道</label>
|
||||
<select value={form.source} onChange={event => setForm({ ...form, source: event.target.value })}>
|
||||
{SOURCES.map(source => <option key={source} value={source}>{source}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
value={form.notes}
|
||||
placeholder="客户提到的交期、认证、目标价等"
|
||||
onChange={event => setForm({ ...form, notes: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{formError && <p className="form-error">{formError}</p>}
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowAdd(false)}>取消</button>
|
||||
{canEdit && (
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || !customers.length}>
|
||||
{saving ? '创建中…' : '创建并去报价'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && customers.length > 0 && inquiries.length > 0 && (
|
||||
<p className="muted-note table-foot-note">
|
||||
<UsersIcon size={13} /> 共 {filtered.length} 条询盘
|
||||
{filtered.some(inquiry => !inquiry.item_count) && ' · 有询盘还没有报价行,点进去添加货号'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InquiryManagement
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { CloudDbIcon, UsersIcon } from '../components/Icons'
|
||||
import { useAuth } from '../auth'
|
||||
import { useBranding, setPageTitle } from '../branding'
|
||||
|
||||
/**
|
||||
* 登录页。
|
||||
*
|
||||
* 用 replace 跳转:登录成功后不该能用浏览器返回键退回登录页。
|
||||
* 若是因为「会话过期」被动登出(顶层守卫带过来的 from),登录后回到原来那一页。
|
||||
*/
|
||||
function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const brand = useBranding()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [form, setForm] = useState({ username: '', password: '' })
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const userRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle('登录')
|
||||
userRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const from = location.state?.from && location.state.from !== '/login' ? location.state.from : '/'
|
||||
|
||||
const submit = async event => {
|
||||
event.preventDefault()
|
||||
if (!form.username.trim()) { setError('请输入用户名'); return }
|
||||
if (!form.password) { setError('请输入密码'); return }
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await login(form.username.trim(), form.password)
|
||||
navigate(from, { replace: true })
|
||||
} catch (submitError) {
|
||||
setError(submitError?.response?.data?.error || '登录失败,请稍后重试')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<aside className="login-brand">
|
||||
<span className="login-brand-mark"><CloudDbIcon size={44} /></span>
|
||||
<h1>{brand.name}</h1>
|
||||
<p className="login-brand-sub">{brand.subtitle}</p>
|
||||
<ul className="login-brand-points">
|
||||
<li>询盘 → 报价 → PI → 采购,一条链路跑完</li>
|
||||
<li>产品与供应商统一目录,团队共享可参考</li>
|
||||
<li>汇率、英文大写金额、包装参数内置</li>
|
||||
</ul>
|
||||
<p className="login-brand-foot">v1 · 第一阶段</p>
|
||||
</aside>
|
||||
|
||||
<form className="login-form" onSubmit={submit}>
|
||||
<header>
|
||||
<h2>账号登录</h2>
|
||||
<p className="login-hint"><UsersIcon size={14} /> 请使用组织分配给你的账号</p>
|
||||
</header>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-username">用户名</label>
|
||||
<input
|
||||
id="login-username"
|
||||
ref={userRef}
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={form.username}
|
||||
onChange={event => setForm(current => ({ ...current, username: event.target.value }))}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">密码</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={form.password}
|
||||
onChange={event => setForm(current => ({ ...current, password: event.target.value }))}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error login-error">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn btn-primary login-submit" disabled={busy}>
|
||||
{busy ? '登录中…' : '登 录'}
|
||||
</button>
|
||||
|
||||
<p className="login-tip">首次使用默认账号 <code>admin</code>,登录后请在「个人资料」里修改密码。</p>
|
||||
</form>
|
||||
</div>
|
||||
<p className="login-copyright">{brand.name} · 外贸供应链管理系统</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
function OrderManagement() {
|
||||
const [orders, setOrders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'order.edit')
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders()
|
||||
@@ -33,14 +37,38 @@ function OrderManagement() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载中...</div>
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">订单管理</h1>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>客户</th>
|
||||
<th>订单日期</th>
|
||||
<th>交货日期</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">订单管理</h1>
|
||||
<button className="btn-primary">+ 新建订单</button>
|
||||
{canEdit && <button className="btn-primary">+ 新建订单</button>}
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
@@ -84,7 +112,7 @@ function OrderManagement() {
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
{canEdit && <button className="btn-link">编辑</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { ShieldIcon, PlusIcon, TrashIcon, SaveIcon } from '../components/Icons'
|
||||
import { useAuth } from '../auth'
|
||||
import { setPageTitle } from '../branding'
|
||||
|
||||
/**
|
||||
* 权限组管理。
|
||||
*
|
||||
* 口径:角色(董事长/总经理/销售经理/销售业务员)只是职位标签,本身不带权限。
|
||||
* 权限组的「权限范围」勾选决定了能访问哪些模块;把角色指派进组,该角色下的用户即生效。
|
||||
* 一个角色同一时间只能属于一个组 —— 所以角色复选框是互斥语义,勾到别的组会把它移过来。
|
||||
*/
|
||||
function PermissionGroups() {
|
||||
const { refresh } = useAuth()
|
||||
const [groups, setGroups] = useState([])
|
||||
const [catalog, setCatalog] = useState({ groups: [], roles: [] })
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
const load = async (keepId) => {
|
||||
const [groupRes, catalogRes] = await Promise.all([
|
||||
axios.get('/api/permission-groups'),
|
||||
axios.get('/api/permissions')
|
||||
])
|
||||
const list = groupRes.data.groups || []
|
||||
setGroups(list)
|
||||
setCatalog({ groups: catalogRes.data.groups || [], roles: catalogRes.data.roles || [] })
|
||||
const target = keepId ?? selectedId
|
||||
const pick = list.find(item => item.id === target) || list[0] || null
|
||||
setSelectedId(pick?.id ?? null)
|
||||
setDraft(pick ? { ...pick, permissions: [...pick.permissions], roles: [...pick.roles] } : null)
|
||||
return list
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle('权限组管理')
|
||||
load().catch(() => setNotice({ type: 'error', text: '加载权限组失败' })).finally(() => setLoading(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const roleGroupMap = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const group of groups) {
|
||||
for (const role of group.roles) map.set(role, group.id)
|
||||
}
|
||||
return map
|
||||
}, [groups])
|
||||
|
||||
const select = id => {
|
||||
const target = groups.find(item => item.id === id)
|
||||
if (!target) return
|
||||
setSelectedId(id)
|
||||
setDraft({ ...target, permissions: [...target.permissions], roles: [...target.roles] })
|
||||
setNotice(null)
|
||||
}
|
||||
|
||||
const togglePermission = key => {
|
||||
setDraft(current => {
|
||||
const has = current.permissions.includes(key)
|
||||
return {
|
||||
...current,
|
||||
permissions: has ? current.permissions.filter(item => item !== key) : [...current.permissions, key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleModulePermissions = (moduleGroups, checked) => {
|
||||
const keys = moduleGroups.items.map(item => item.key)
|
||||
setDraft(current => ({
|
||||
...current,
|
||||
permissions: checked
|
||||
? [...new Set([...current.permissions, ...keys])]
|
||||
: current.permissions.filter(item => !keys.includes(item))
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleRole = role => {
|
||||
setDraft(current => {
|
||||
const has = current.roles.includes(role)
|
||||
return { ...current, roles: has ? current.roles.filter(item => item !== role) : [...current.roles, role] }
|
||||
})
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!draft) return
|
||||
if (!draft.name.trim()) { setNotice({ type: 'error', text: '权限组名称不能为空' }); return }
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const { data } = await axios.patch(`/api/permission-groups/${draft.id}`, {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description,
|
||||
permissions: draft.permissions,
|
||||
roles: draft.roles
|
||||
})
|
||||
await load(data.group.id)
|
||||
await refresh() /* 可能改到了自己的角色所属组,刷新自身权限 */
|
||||
setNotice({ type: 'ok', text: '权限组已保存' })
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '保存失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createGroup = async () => {
|
||||
const name = window.prompt('新权限组名称', '新权限组')
|
||||
if (name === null) return
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const { data } = await axios.post('/api/permission-groups', {
|
||||
name: name.trim(),
|
||||
description: '',
|
||||
permissions: ['dashboard.view'],
|
||||
roles: []
|
||||
})
|
||||
await load(data.group.id)
|
||||
setNotice({ type: 'ok', text: `权限组「${data.group.name}」已创建` })
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '创建失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeGroup = async () => {
|
||||
if (!draft) return
|
||||
if (!window.confirm(`确定删除权限组「${draft.name}」?该操作不可撤销。`)) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await axios.delete(`/api/permission-groups/${draft.id}`)
|
||||
await load(null)
|
||||
setNotice({ type: 'ok', text: '权限组已删除' })
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '删除失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="page-container"><p className="muted-note">正在加载权限组…</p></div>
|
||||
|
||||
return (
|
||||
<div className="page-container perm-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">权限组管理</h1>
|
||||
<p className="page-subtitle">
|
||||
角色只是职位标签;把角色放进某个权限组,它就获得该组勾选的权限范围。一个角色同时只能属于一个组。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={createGroup} disabled={busy}>
|
||||
<PlusIcon size={15} /> 新建权限组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
|
||||
|
||||
<div className="perm-layout">
|
||||
{/* ---------------- 左:组列表 ---------------- */}
|
||||
<aside className="perm-list panel">
|
||||
<div className="panel-title">权限组({groups.length})</div>
|
||||
{groups.map(group => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className={`perm-list-item${group.id === selectedId ? ' active' : ''}`}
|
||||
onClick={() => select(group.id)}
|
||||
>
|
||||
<span className="perm-list-name">
|
||||
<ShieldIcon size={14} /> {group.name}
|
||||
{group.is_system ? <i className="perm-tag">系统</i> : null}
|
||||
</span>
|
||||
<span className="perm-list-meta">
|
||||
{group.permissions.length} 项权限 · {group.roles.length} 个角色
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{/* ---------------- 右:编辑区 ---------------- */}
|
||||
<section className="panel perm-editor">
|
||||
{!draft ? (
|
||||
<p className="muted-note">左侧选择一个权限组,或新建一个。</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-title">
|
||||
{draft.name}
|
||||
{draft.is_system ? <i className="perm-tag">系统内置 · 不可删除</i> : null}
|
||||
</div>
|
||||
|
||||
<div className="perm-basic">
|
||||
<div className="form-group">
|
||||
<label>名称</label>
|
||||
<input type="text" value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>说明</label>
|
||||
<input type="text" value={draft.description} placeholder="这个组大致管什么" onChange={event => setDraft({ ...draft, description: event.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="perm-section-title">权限范围</div>
|
||||
<div className="perm-modules">
|
||||
{catalog.groups.map(module => {
|
||||
const keys = module.items.map(item => item.key)
|
||||
const checkedCount = keys.filter(key => draft.permissions.includes(key)).length
|
||||
const allChecked = checkedCount === keys.length
|
||||
return (
|
||||
<div className="perm-module" key={module.title}>
|
||||
<label className="perm-module-head">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={element => { if (element) element.indeterminate = checkedCount > 0 && !allChecked }}
|
||||
onChange={event => toggleModulePermissions(module, event.target.checked)}
|
||||
/>
|
||||
<b>{module.title}</b>
|
||||
<span className="perm-module-count">{checkedCount}/{keys.length}</span>
|
||||
</label>
|
||||
<div className="perm-module-items">
|
||||
{module.items.map(item => (
|
||||
<label className="perm-item" key={item.key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.permissions.includes(item.key)}
|
||||
onChange={() => togglePermission(item.key)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="perm-section-title">角色指派(放进本组的角色即获得上面的权限)</div>
|
||||
<div className="perm-roles">
|
||||
{catalog.roles.map(role => {
|
||||
const owner = roleGroupMap.get(role.value)
|
||||
const here = draft.roles.includes(role.value) || owner === draft.id
|
||||
const elsewhere = owner && owner !== draft.id
|
||||
const otherName = elsewhere ? groups.find(group => group.id === owner)?.name : null
|
||||
return (
|
||||
<label className={`perm-role${here ? ' checked' : ''}`} key={role.value}>
|
||||
<input type="checkbox" checked={!!here} onChange={() => toggleRole(role.value)} />
|
||||
<span>
|
||||
<b>{role.label}</b>
|
||||
{otherName ? <small>当前在「{otherName}」,勾选将改派到本组</small> : <small>{here ? '已属于本组' : '未指派'}</small>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="perm-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
|
||||
<SaveIcon size={15} /> {busy ? '保存中…' : '保存权限组'}
|
||||
</button>
|
||||
{!draft.is_system && (
|
||||
<button type="button" className="btn btn-danger" onClick={removeGroup} disabled={busy}>
|
||||
<TrashIcon size={14} /> 删除该组
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionGroups
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
/**
|
||||
* PI 合同管理(Proforma Invoice)
|
||||
* 数据来源:happy 后端 /api/pi-contracts(P1 后端合并阶段开放)
|
||||
*/
|
||||
function PiContractManagement() {
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'contract.edit')
|
||||
const [contracts, setContracts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
@@ -31,7 +35,7 @@ function PiContractManagement() {
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">PI 合同管理</h1>
|
||||
<button className="btn btn-primary">+ 新建 PI</button>
|
||||
{canEdit && <button className="btn btn-primary">+ 新建 PI</button>}
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
@@ -62,7 +66,7 @@ function PiContractManagement() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
@@ -81,7 +85,7 @@ function PiContractManagement() {
|
||||
<td><span className={`status-badge status-${c.status}`}>{statusText(c.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">生成单据</button>
|
||||
{canEdit && <button className="btn-link">生成单据</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
CartIcon,
|
||||
ImageIcon,
|
||||
TrashIcon,
|
||||
PencilIcon,
|
||||
CloseIcon,
|
||||
PlusCircleIcon
|
||||
PlusCircleIcon,
|
||||
ArrowLeftIcon,
|
||||
UploadIcon,
|
||||
CopyIcon,
|
||||
FingerprintIcon
|
||||
} from '../components/Icons'
|
||||
import SearchSelect, { SearchField } from '../components/SearchSelect'
|
||||
import PackagingPanel from '../components/PackagingPanel'
|
||||
import { SkuThumb } from '../components/SkuThumb'
|
||||
import { makeMountId, pushLayer, goBackOne, popLayers, useLayerRestore } from '../hooks/useLayerHistory'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
/**
|
||||
* 产品管理(原生 React 版)
|
||||
@@ -15,11 +25,18 @@ import {
|
||||
* 由 SKU Manager 的「产品查询」视图迁移而来,功能对齐:
|
||||
* 检索 / 供应商与待核对筛选 / 反选批量 / 产品详情(货号×参数对比) / 产品与货号编辑
|
||||
* / 图片管理 / 小推车。数据统一经 /api/sku/* 同源代理,禁止硬编码 SKU 端口。
|
||||
*
|
||||
* 两级浮层(产品详情抽屉 → 编辑货号)都挂进了浏览器历史,
|
||||
* 所以鼠标侧键 / Alt+← / 系统返回手势都能逐级退回,详见 hooks/useLayerHistory.js。
|
||||
*/
|
||||
|
||||
const API = '/api/sku'
|
||||
const ATTR_LIST_ID = 'sku-attr-names'
|
||||
|
||||
/* 两级浮层在历史条目里的键名(各自读取,互不干扰) */
|
||||
const LAYER_PANEL = 'tmsProductPanel'
|
||||
const LAYER_GROUP_MODE = 'tmsGroupMode'
|
||||
|
||||
/* ------------------------------ 格式化工具 ------------------------------ */
|
||||
|
||||
const money = (n) => (n == null ? '—' : Number(n).toFixed(2))
|
||||
@@ -51,8 +68,9 @@ const thumbSrc = (filename) => `/product-images/${encodeURIComponent(filename)}`
|
||||
|
||||
/* ------------------------------ 小部件 ------------------------------ */
|
||||
|
||||
/** 关键:图片文件名来自后端,统一走同源代理地址,不拼 3300 */
|
||||
function Thumb({ filename, count, size = 38 }) {
|
||||
/** 关键:图片文件名来自后端,统一走同源地址 /product-images/*,不拼端口。
|
||||
* 悬浮可弹出大图预览(sku 传进去只为预览卡上能标出货号)。 */
|
||||
function Thumb({ filename, count, size = 38, sku }) {
|
||||
const style = { width: size, height: size }
|
||||
if (!filename) {
|
||||
return (
|
||||
@@ -61,12 +79,15 @@ function Thumb({ filename, count, size = 38 }) {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="thumb-wrap" style={style}>
|
||||
<img className="thumb" style={style} src={thumbSrc(filename)} alt="" loading="lazy" />
|
||||
{count > 1 && <span className="thumb-badge">{count}</span>}
|
||||
</span>
|
||||
)
|
||||
if (count > 1) {
|
||||
return (
|
||||
<span className="thumb-wrap" style={style}>
|
||||
<SkuThumb sku={sku} filename={filename} size={size} />
|
||||
<span className="thumb-badge">{count}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <SkuThumb sku={sku} filename={filename} size={size} />
|
||||
}
|
||||
|
||||
/** 参数编辑器:一组「参数名 / 值」,支持增删 */
|
||||
@@ -110,32 +131,76 @@ function AttrEditor({ attrs, onChange }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 供应商下拉:空值 = 未指定 */
|
||||
/** 主营类目取前两段当副标题,方便在候选里区分同名/近名供应商 */
|
||||
function categoryHint(text, take = 2) {
|
||||
const parts = String(text || '').split(/[、,,;;/|\s]+/).filter(Boolean)
|
||||
return parts.slice(0, take).join(' / ')
|
||||
}
|
||||
|
||||
/** 供应商候选:把「未指定」与真实档案统一成一组下拉项 */
|
||||
function supplierOptionList(suppliers, noneLabel = '未指定供应商') {
|
||||
return [
|
||||
{ value: '', label: noneLabel },
|
||||
...suppliers.map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
hint: categoryHint(s.main_categories)
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
/** 供应商下拉:空值 = 未指定;供应商几百家,必须能打字搜 */
|
||||
function SupplierSelect({ value, onChange, suppliers, style }) {
|
||||
const options = useMemo(() => supplierOptionList(suppliers), [suppliers])
|
||||
return (
|
||||
<select value={value ?? ''} onChange={onChange} style={style}>
|
||||
<option value="">未指定供应商</option>
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<SearchSelect
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
placeholder="未指定供应商"
|
||||
searchPlaceholder="输入供应商名 / 主营类目搜索…"
|
||||
emptyText="没有匹配的供应商"
|
||||
clearable
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------ 抽屉外壳 ------------------------------ */
|
||||
|
||||
function Drawer({ wide, title, sub, onClose, headerExtra, footer, children }) {
|
||||
/**
|
||||
* 通用抽屉。
|
||||
* onBack 给「有下一层」的场景用,会在标题左侧出现一个返回键
|
||||
* (与鼠标侧键等价,都走浏览器历史,见 useLayerHistory)。
|
||||
* onEscape Esc 的行为。有下一层时应该"退一层",没有时才"关抽屉"。
|
||||
*/
|
||||
function Drawer({ wide, title, sub, onBack, onEscape, onClose, headerExtra, footer, children }) {
|
||||
// 用 ref 存回调,键盘监听只挂一次;否则每次重渲染都要重新绑定
|
||||
const escRef = useRef(onEscape || onClose)
|
||||
escRef.current = onEscape || onClose
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||
const onKey = (e) => { if (e.key === 'Escape') escRef.current() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-backdrop" onClick={onClose} />
|
||||
<aside className={`drawer${wide ? ' drawer-wide' : ''}`} role="dialog" aria-modal="true">
|
||||
<div className="drawer-header">
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
className="drawer-back"
|
||||
onClick={onBack}
|
||||
title="返回上一层(鼠标侧键 / Alt + ← 同样可用)"
|
||||
>
|
||||
<ArrowLeftIcon size={15} />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="drawer-head-main">
|
||||
<h2 className="drawer-title">{title}</h2>
|
||||
{sub && <p className="drawer-sub">{sub}</p>}
|
||||
@@ -156,7 +221,7 @@ function Drawer({ wide, title, sub, onClose, headerExtra, footer, children }) {
|
||||
|
||||
/* ============================ 产品详情抽屉 ============================ */
|
||||
|
||||
function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
function GroupDrawer({ mountId, groupId, highlightSku, suppliers, onClose, onChanged, onModeChange, canEdit }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [mode, setMode] = useState({ kind: 'view' })
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -164,6 +229,31 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
const [lightbox, setLightbox] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
/* 勾选要加入小推车的货号,以及"已经在车里"的货号 —— 后者让勾选框能显示成不可重复添加 */
|
||||
const [picked, setPicked] = useState(() => new Set())
|
||||
const [cartSkus, setCartSkus] = useState(() => new Set())
|
||||
const [cartBusy, setCartBusy] = useState(false)
|
||||
const [cartNotice, setCartNotice] = useState('')
|
||||
|
||||
/* 把当前编辑态报给父级:父级据此算出「本页现在压了几层历史」,
|
||||
关抽屉时才能精确退回去(层数不能从历史里读,见 hooks/useLayerHistory.js) */
|
||||
useEffect(() => { onModeChange?.(mode) }, [mode, onModeChange])
|
||||
|
||||
/* 编辑态挂进浏览器历史:顶部返回键、鼠标侧键、Alt + ←、系统返回手势走的是同一条路 */
|
||||
const openMode = useCallback((next) => {
|
||||
pushLayer(mountId, LAYER_GROUP_MODE, next)
|
||||
setMode(next)
|
||||
}, [mountId])
|
||||
|
||||
const backOne = useCallback(() => {
|
||||
// 有本页的层就走历史(状态由 popstate 回写),没有才直接收 —— 兜底,正常不会走到
|
||||
if (!goBackOne(mountId)) setMode({ kind: 'view' })
|
||||
}, [mountId])
|
||||
|
||||
useLayerRestore(mountId, LAYER_GROUP_MODE, (snapshot) => {
|
||||
setMode(snapshot && snapshot.kind ? snapshot : { kind: 'view' })
|
||||
}, '/products')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
@@ -196,6 +286,75 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
return cols
|
||||
}, [items])
|
||||
|
||||
/* 货号一多(一个产品几十个货号)也要能搜:货号 / 规格 / 参数都能命中 */
|
||||
const [itemQuery, setItemQuery] = useState('')
|
||||
const visibleItems = useMemo(() => {
|
||||
const kw = itemQuery.trim().toLowerCase()
|
||||
if (!kw) return items
|
||||
return items.filter((it) => [
|
||||
it.display_sku,
|
||||
it.sku,
|
||||
it.spec,
|
||||
...(it.attributes || []).map((a) => `${a.name} ${a.value}`)
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(kw))
|
||||
}, [items, itemQuery])
|
||||
|
||||
/* --------------------------- 货号勾选 / 加入小推车 --------------------------- */
|
||||
|
||||
const loadCart = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${API}/cart`)
|
||||
setCartSkus(new Set((Array.isArray(data) ? data : []).map((r) => r.sku)))
|
||||
} catch { /* 小推车读不到不影响看货号,勾选时再加也不迟 */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadCart() }, [loadCart])
|
||||
|
||||
const togglePick = (sku) => {
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sku)) next.delete(sku)
|
||||
else next.add(sku)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/* 全选 / 反选只作用于「当前筛出来的货号」,否则搜完再全选会勾到看不见的货号 */
|
||||
const pickableVisible = useMemo(
|
||||
() => visibleItems.filter((it) => !cartSkus.has(it.sku)),
|
||||
[visibleItems, cartSkus]
|
||||
)
|
||||
const allVisiblePicked = pickableVisible.length > 0 && pickableVisible.every((it) => picked.has(it.sku))
|
||||
const someVisiblePicked = pickableVisible.some((it) => picked.has(it.sku)) && !allVisiblePicked
|
||||
|
||||
const toggleAllVisible = () => {
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (allVisiblePicked) pickableVisible.forEach((it) => next.delete(it.sku))
|
||||
else pickableVisible.forEach((it) => next.add(it.sku))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const addPickedToCart = async () => {
|
||||
if (!picked.size || cartBusy) return
|
||||
const skus = [...picked]
|
||||
setCartBusy(true)
|
||||
setCartNotice('')
|
||||
setError('')
|
||||
try {
|
||||
const { data } = await axios.post(`${API}/cart`, { skus })
|
||||
setCartSkus((prev) => new Set([...prev, ...skus]))
|
||||
setPicked(new Set())
|
||||
setCartNotice(data.added ? `已加入 ${data.added} 个货号到小推车` : '选中的货号都已经在小推车里了')
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.error || `加入小推车失败:${e.message}`)
|
||||
} finally {
|
||||
setCartBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadImages = async (e) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (!files.length) return
|
||||
@@ -263,9 +422,11 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
|
||||
if (mode.kind === 'view') {
|
||||
headerExtra = (
|
||||
<button className="btn btn-sm" onClick={() => setMode({ kind: 'editGroup' })}>
|
||||
<PencilIcon size={14} /> 编辑产品
|
||||
</button>
|
||||
canEdit && (
|
||||
<button className="btn btn-sm" onClick={() => openMode({ kind: 'editGroup' })}>
|
||||
<PencilIcon size={14} /> 编辑产品
|
||||
</button>
|
||||
)
|
||||
)
|
||||
|
||||
body = (
|
||||
@@ -292,10 +453,44 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-title">货号与参数</div>
|
||||
{items.length > 1 && (
|
||||
<div className="panel-search-row">
|
||||
<SearchField size="sm" value={itemQuery} onChange={setItemQuery} placeholder="搜货号 / 规格 / 参数…" />
|
||||
{itemQuery !== '' && <span className="secondary">{visibleItems.length} / {items.length} 个货号</span>}
|
||||
</div>
|
||||
)}
|
||||
{cartNotice && <div className="notice-inline notice-ok" style={{ marginBottom: 10 }}>{cartNotice}</div>}
|
||||
|
||||
{picked.size > 0 && (
|
||||
<div className="batch-bar">
|
||||
<span>已选 <b>{picked.size}</b> 个货号</span>
|
||||
<button className="btn btn-sm" onClick={() => setPicked(new Set(pickableVisible.map((it) => it.sku)))}>
|
||||
全选当前 {pickableVisible.length} 个
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setPicked(new Set())}>取消选择</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
{canEdit && (
|
||||
<button className="btn btn-sm btn-primary" onClick={addPickedToCart} disabled={cartBusy}>
|
||||
<CartIcon size={14} /> {cartBusy ? '加入中…' : '加入小推车'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-container" style={{ maxHeight: 380 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 42 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisiblePicked}
|
||||
ref={(el) => { if (el) el.indeterminate = someVisiblePicked }}
|
||||
onChange={toggleAllVisible}
|
||||
disabled={pickableVisible.length === 0}
|
||||
title="全选 / 取消全选当前货号"
|
||||
/>
|
||||
</th>
|
||||
<th>货号</th>
|
||||
<th>规格</th>
|
||||
<th style={{ textAlign: 'right' }}>采购价</th>
|
||||
@@ -305,18 +500,51 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan={5 + attrCols.length} className="secondary" style={{ textAlign: 'center' }}>还没有货号</td></tr>
|
||||
) : items.map((it) => {
|
||||
{visibleItems.length === 0 ? (
|
||||
<tr><td colSpan={6 + attrCols.length} className="secondary" style={{ textAlign: 'center' }}>
|
||||
{items.length === 0 ? '还没有货号' : '没有匹配的货号'}
|
||||
</td></tr>
|
||||
) : visibleItems.map((it) => {
|
||||
const attrMap = {}
|
||||
for (const a of it.attributes || []) attrMap[a.name] = a.value
|
||||
const inCart = cartSkus.has(it.sku)
|
||||
return (
|
||||
<tr
|
||||
key={it.sku}
|
||||
className={highlightSku && it.sku === highlightSku ? 'row-hit' : ''}
|
||||
onClick={() => setMode({ kind: 'editItem', sku: it.sku })}
|
||||
onClick={() => openMode({ kind: 'editItem', sku: it.sku })}
|
||||
>
|
||||
<td className="mono bold">{it.display_sku || it.sku}</td>
|
||||
{/* 勾选框只用来挑「加入小推车」,点它不该顺手进编辑页,所以掐掉冒泡 */}
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inCart || picked.has(it.sku)}
|
||||
disabled={inCart}
|
||||
onChange={() => togglePick(it.sku)}
|
||||
title={inCart ? '已在小推车里' : '勾选后可加入小推车'}
|
||||
/>
|
||||
</td>
|
||||
<td className="mono bold">
|
||||
<span className="sku-cell">
|
||||
<SkuThumb sku={it.sku} filename={it.thumb} size={30} />
|
||||
<span className="sku-cell-text">
|
||||
{it.display_sku || it.sku}
|
||||
{it.duplicated_from && (
|
||||
<span className="pill pill-copy" title={`上传时货号 ${it.duplicated_from} 已存在,系统自动建了这个副本`}>
|
||||
<CopyIcon size={11} /> 副本
|
||||
</span>
|
||||
)}
|
||||
{inCart && <span className="pill pill-ok">已在车</span>}
|
||||
</span>
|
||||
</span>
|
||||
{/* 谁传的:产品库是组织共享的,来源必须一眼看得见 */}
|
||||
<span
|
||||
className={`sku-uploader${it.uploader ? '' : ' is-quiet'}`}
|
||||
title={it.uploader ? `由 ${it.uploader} 上传` : '这条是留痕功能上线前录入的,没有上传者信息'}
|
||||
>
|
||||
<UploadIcon size={11} /> {it.uploader || '来源未记录'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="secondary">{it.spec || '—'}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>
|
||||
<span className="unit">¥</span>{money(it.price)}
|
||||
@@ -324,7 +552,7 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{it.moq ?? '—'}</td>
|
||||
{attrCols.map((c) => <td key={c} className="secondary">{attrMap[c] || '—'}</td>)}
|
||||
<td><button className="btn btn-sm">编辑</button></td>
|
||||
<td>{canEdit && <button className="btn btn-sm">编辑</button>}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
@@ -332,9 +560,11 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 11 }}>
|
||||
<button className="btn btn-sm" onClick={() => setMode({ kind: 'newItem' })}>
|
||||
<PlusCircleIcon size={14} /> 新增货号
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-sm" onClick={() => openMode({ kind: 'newItem' })}>
|
||||
<PlusCircleIcon size={14} /> 新增货号
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,20 +580,24 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
/>
|
||||
{img.is_primary === 1 && <span className="gallery-flag">封面</span>}
|
||||
<div className="gallery-actions">
|
||||
{img.is_primary !== 1 && (
|
||||
{img.is_primary !== 1 && canEdit && (
|
||||
<button className="btn btn-sm" onClick={() => setPrimary(img.id)}>设封面</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-danger" onClick={() => removeImage(img.id)}>
|
||||
<TrashIcon size={13} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-sm btn-danger" onClick={() => removeImage(img.id)}>
|
||||
<TrashIcon size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<label className="add-image">
|
||||
<PlusCircleIcon size={18} />
|
||||
<span>{busy ? '上传中…' : '添加图片'}</span>
|
||||
<input type="file" accept="image/*" multiple onChange={uploadImages} disabled={busy} />
|
||||
</label>
|
||||
{canEdit && (
|
||||
<label className="add-image">
|
||||
<PlusCircleIcon size={18} />
|
||||
<span>{busy ? '上传中…' : '添加图片'}</span>
|
||||
<input type="file" accept="image/*" multiple onChange={uploadImages} disabled={busy} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -376,8 +610,9 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
group={g}
|
||||
groupAttributes={groupAttributes}
|
||||
suppliers={suppliers}
|
||||
onCancel={() => setMode({ kind: 'view' })}
|
||||
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
|
||||
onCancel={backOne}
|
||||
onSaved={async () => { backOne(); await refresh() }}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
)
|
||||
} else if (mode.kind === 'newItem') {
|
||||
@@ -388,8 +623,18 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
groupId={groupId}
|
||||
group={g}
|
||||
items={items}
|
||||
onCancel={() => setMode({ kind: 'view' })}
|
||||
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
|
||||
onCancel={backOne}
|
||||
canEdit={canEdit}
|
||||
onSaved={async (info) => {
|
||||
backOne()
|
||||
await refresh()
|
||||
/* 撞号时后端不会再报错,而是落成副本 —— 得明说一句,否则用户会以为写错了货号 */
|
||||
if (info?.duplicated_from) {
|
||||
setCartNotice(`货号 ${info.duplicated_from} 已存在,已自动建成副本「${info.display_sku}」,并记下上传者`)
|
||||
} else {
|
||||
setCartNotice('货号已新增,并记下了上传者')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else if (mode.kind === 'editItem') {
|
||||
@@ -400,16 +645,30 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
sku={mode.sku}
|
||||
groupId={groupId}
|
||||
suppliers={suppliers}
|
||||
onBack={() => setMode({ kind: 'view' })}
|
||||
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
|
||||
onBack={backOne}
|
||||
onSaved={async () => { backOne(); await refresh() }}
|
||||
onDeleted={async () => { onClose(); onChanged?.() }}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* 在子层(编辑产品 / 新增货号 / 编辑货号)时,标题左侧出现返回键;
|
||||
Esc 也退一层,退到最外层再按一次才关抽屉 —— 免得改了一半直接被关掉 */
|
||||
const inSubLayer = mode.kind !== 'view'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer wide title={title} sub={sub} onClose={onClose} headerExtra={headerExtra} footer={footer}>
|
||||
<Drawer
|
||||
wide
|
||||
title={title}
|
||||
sub={sub}
|
||||
onBack={inSubLayer ? backOne : undefined}
|
||||
onEscape={inSubLayer ? backOne : onClose}
|
||||
onClose={onClose}
|
||||
headerExtra={headerExtra}
|
||||
footer={footer}
|
||||
>
|
||||
{body}
|
||||
</Drawer>
|
||||
{lightbox && (
|
||||
@@ -423,7 +682,7 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
|
||||
|
||||
/* ---------------------------- 产品编辑表单 ---------------------------- */
|
||||
|
||||
function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
|
||||
function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved, canEdit }) {
|
||||
const [form, setForm] = useState({
|
||||
name: group.name || '',
|
||||
brand: group.brand || '',
|
||||
@@ -479,7 +738,7 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>供应商</label>
|
||||
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(e) => set('supplier_id', e.target.value)} />
|
||||
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(v) => set('supplier_id', v)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>产品说明</label>
|
||||
@@ -491,9 +750,11 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={onCancel} disabled={saving}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -502,7 +763,7 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
|
||||
|
||||
/* ---------------------------- 货号新增表单 ---------------------------- */
|
||||
|
||||
function ItemForm({ groupId, group, items, onCancel, onSaved }) {
|
||||
function ItemForm({ groupId, group, items, onCancel, onSaved, canEdit }) {
|
||||
const [form, setForm] = useState({ sku: '', spec: '', price: '', moq: '', description: '' })
|
||||
const [attrs, setAttrs] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
@@ -527,8 +788,7 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
|
||||
description: form.description,
|
||||
name: group.name,
|
||||
attributes: attrs.filter((a) => a.name.trim())
|
||||
})
|
||||
await onSaved()
|
||||
}).then(({ data }) => onSaved(data))
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.error || `创建失败:${e.message}`)
|
||||
} finally {
|
||||
@@ -568,12 +828,18 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
|
||||
{items.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label>从现有货号复制参数</label>
|
||||
<select defaultValue="" onChange={(e) => copyFrom(e.target.value)}>
|
||||
<option value="">不复制</option>
|
||||
{items.map((it) => (
|
||||
<option key={it.sku} value={it.sku}>{it.display_sku || it.sku}</option>
|
||||
))}
|
||||
</select>
|
||||
<SearchSelect
|
||||
value=""
|
||||
onChange={(v) => { if (v) copyFrom(v) }}
|
||||
options={items.map((it) => ({
|
||||
value: it.sku,
|
||||
label: it.display_sku || it.sku,
|
||||
hint: it.spec || ''
|
||||
}))}
|
||||
placeholder="不复制"
|
||||
searchPlaceholder="输入货号 / 规格搜索…"
|
||||
emptyText="没有匹配的货号"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
@@ -582,9 +848,11 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '创建中…' : '创建'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '创建中…' : '创建'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={onCancel} disabled={saving}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -593,7 +861,7 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
|
||||
|
||||
/* ---------------------------- 货号编辑器 ---------------------------- */
|
||||
|
||||
function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
|
||||
function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted, canEdit }) {
|
||||
const [product, setProduct] = useState(null)
|
||||
const [history, setHistory] = useState([])
|
||||
const [groupChoices, setGroupChoices] = useState([])
|
||||
@@ -700,6 +968,18 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
|
||||
<div className="panel-title">编辑货号 {product.display_sku || product.sku}</div>
|
||||
{error && <div className="notice-inline">{error}</div>}
|
||||
|
||||
{/* 溯源条:产品库是组织共享的,谁传的、是不是撞号生成的副本必须一眼看得见 */}
|
||||
<div className="provenance">
|
||||
{product.uploader
|
||||
? <span className="provenance-item"><UploadIcon size={13} /> 上传者 <b>{product.uploader}</b></span>
|
||||
: <span className="provenance-item is-quiet" title="这条产品是在留痕功能上线前录入的"><UploadIcon size={13} /> 上传者未记录</span>}
|
||||
{product.duplicated_from && (
|
||||
<span className="provenance-item is-copy" title={`上传时货号 ${product.duplicated_from} 已存在,系统自动建了这个副本`}>
|
||||
<CopyIcon size={13} /> 同货号副本 · 复制自 <b>{product.duplicated_from}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>采购价{priceEditable ? '' : '(合同锁定)'}</label>
|
||||
@@ -733,17 +1013,24 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
|
||||
|
||||
<div className="form-group">
|
||||
<label>所属产品</label>
|
||||
<select value={form.group_id} onChange={(e) => set('group_id', e.target.value)}>
|
||||
{groupChoices.map((gc) => (
|
||||
<option key={gc.id} value={gc.id}>
|
||||
{gc.name}({gc.item_count} 个货号)
|
||||
</option>
|
||||
))}
|
||||
{String(product.group_id) && !groupChoices.some((gc) => String(gc.id) === String(product.group_id)) && (
|
||||
<option value={product.group_id}>{product.group_name || '当前产品'}</option>
|
||||
)}
|
||||
<option value="new">— 独立成新产品 —</option>
|
||||
</select>
|
||||
<SearchSelect
|
||||
value={form.group_id}
|
||||
onChange={(v) => set('group_id', v)}
|
||||
options={[
|
||||
...groupChoices.map((gc) => ({
|
||||
value: String(gc.id),
|
||||
label: gc.name,
|
||||
hint: `${gc.item_count} 个货号`
|
||||
})),
|
||||
...(String(product.group_id) && !groupChoices.some((gc) => String(gc.id) === String(product.group_id))
|
||||
? [{ value: String(product.group_id), label: product.group_name || '当前产品' }]
|
||||
: []),
|
||||
{ value: 'new', label: '— 独立成新产品 —' }
|
||||
]}
|
||||
placeholder="选择所属产品"
|
||||
searchPlaceholder="输入产品名搜索…"
|
||||
emptyText="没有匹配的产品"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -752,13 +1039,17 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={onBack} disabled={saving}>返回</button>
|
||||
<button className="btn btn-danger" onClick={remove} disabled={saving}>
|
||||
<TrashIcon size={14} /> 删除货号
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-danger" onClick={remove} disabled={saving}>
|
||||
<TrashIcon size={14} /> 删除货号
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -771,6 +1062,13 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 包装信息:结构化录入单品/内盒/外箱/重量,并据此推算装柜量。
|
||||
数据存在 happy 自己的库(product_packaging),与 SKU 服务的价格数据分开维护。 */}
|
||||
<div className="panel">
|
||||
<div className="panel-title">包装信息</div>
|
||||
<PackagingPanel sku={product.sku} canEdit={canEdit} />
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-title">调价记录</div>
|
||||
{history.length === 0 ? (
|
||||
@@ -792,6 +1090,13 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
|
||||
{h.source_contract ? ` · ${h.source_contract}` : ''}
|
||||
{h.supplier_name ? ` · ${h.supplier_name}` : ''}
|
||||
</div>
|
||||
{/* 谁改的价:每次调价都要能追到操作人 */}
|
||||
<div className="timeline-op">
|
||||
<FingerprintIcon size={12} />
|
||||
{h.changed_by
|
||||
? <>操作人 <b>{h.changed_by}</b></>
|
||||
: <span className="is-quiet">操作人未记录</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -804,7 +1109,7 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
|
||||
|
||||
/* ---------------------------- 新增产品抽屉 ---------------------------- */
|
||||
|
||||
function NewProductDrawer({ suppliers, onClose, onCreated }) {
|
||||
function NewProductDrawer({ suppliers, onClose, onCreated, canEdit }) {
|
||||
const [form, setForm] = useState({
|
||||
name: '', brand: '', category: '', supplier_id: '', description: '',
|
||||
sku: '', spec: '', price: '', moq: '', itemDesc: ''
|
||||
@@ -835,7 +1140,7 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
|
||||
attributes: attrs.filter((a) => a.name.trim())
|
||||
}
|
||||
})
|
||||
onCreated?.(data.group_id)
|
||||
onCreated?.(data)
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.error || `创建失败:${e.message}`)
|
||||
} finally {
|
||||
@@ -850,9 +1155,11 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '创建中…' : '创建'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '创建中…' : '创建'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={onClose} disabled={saving}>取消</button>
|
||||
</>
|
||||
}
|
||||
@@ -877,7 +1184,7 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>供应商</label>
|
||||
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(e) => set('supplier_id', e.target.value)} />
|
||||
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(v) => set('supplier_id', v)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>产品说明</label>
|
||||
@@ -922,7 +1229,8 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
|
||||
|
||||
/* ------------------------------ 小推车抽屉 ------------------------------ */
|
||||
|
||||
function CartDrawer({ onClose, onChanged }) {
|
||||
function CartDrawer({ onClose, onChanged, canEdit, canQuoteEdit }) {
|
||||
const navigate = useNavigate()
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -991,10 +1299,15 @@ function CartDrawer({ onClose, onChanged }) {
|
||||
{noPrice > 0 && <span className="secondary" style={{ marginLeft: 10 }}>{noPrice} 个货号库里没有采购价</span>}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="btn btn-sm btn-danger" onClick={clear}>清空</button>
|
||||
<button className="btn btn-primary" onClick={() => window.alert('汇总报价下一步接入:需要先定「从 CRM 选客户 / 自动带出单证字段」的方案,方案定了我就把报价单那步补上。')}>
|
||||
汇总报价 →
|
||||
</button>
|
||||
{canEdit && <button className="btn btn-sm btn-danger" onClick={clear}>清空</button>}
|
||||
{/* 这里刻意不走 onClose(那会触发 history.go)。
|
||||
直接切路由,产品页整体卸载、抽屉一并消失即可;
|
||||
留在历史里的那条层条目靠 mountId 认得出来,回头不会冒出幽灵抽屉。 */}
|
||||
{canQuoteEdit && (
|
||||
<button className="btn btn-primary" onClick={() => navigate('/quotes/new')}>
|
||||
汇总报价 →
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
@@ -1035,17 +1348,35 @@ function CartDrawer({ onClose, onChanged }) {
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
data-cart-qty=""
|
||||
placeholder={r.moq ? String(r.moq) : ''}
|
||||
defaultValue={r.qty ?? ''}
|
||||
onBlur={(e) => {
|
||||
if (String(r.qty ?? '') !== e.target.value) setQty(r.sku, e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return
|
||||
e.preventDefault()
|
||||
// 回车 = 存下这一行并跳到下一行的数量框,连续录入不用碰鼠标。
|
||||
// 焦点一移开,上面的 onBlur 就会把当前值写回去。
|
||||
const list = e.currentTarget.closest('.cart-list')
|
||||
const inputs = list ? Array.from(list.querySelectorAll('input[data-cart-qty]')) : []
|
||||
const next = inputs[inputs.indexOf(e.currentTarget) + 1]
|
||||
if (next) {
|
||||
next.focus()
|
||||
next.select() // 选中已有数字,直接输入即可覆盖
|
||||
next.scrollIntoView({ block: 'nearest' })
|
||||
} else {
|
||||
e.currentTarget.blur() // 最后一行:回车收尾
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn-sm btn-icon btn-danger" title="移出小推车" onClick={() => removeRow(r.sku)}>
|
||||
<CloseIcon size={14} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-sm btn-icon btn-danger" title="移出小推车" onClick={() => removeRow(r.sku)}>
|
||||
<CloseIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -1063,6 +1394,10 @@ function ProductManagement() {
|
||||
const [supplierFilter, setSupplierFilter] = useState('')
|
||||
const [onlyLow, setOnlyLow] = useState(false)
|
||||
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'product.edit')
|
||||
const canQuoteEdit = hasPermission(user, 'quote.edit')
|
||||
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notice, setNotice] = useState('')
|
||||
@@ -1072,8 +1407,60 @@ function ProductManagement() {
|
||||
const [attrNames, setAttrNames] = useState([])
|
||||
const [cartCount, setCartCount] = useState(0)
|
||||
|
||||
/* 筛选用的供应商候选:多两个「全部 / 未分类」的伪选项 */
|
||||
const supplierFilterOptions = useMemo(() => [
|
||||
{ value: '', label: '全部供应商' },
|
||||
{ value: 'none', label: '未分类(无供应商)' },
|
||||
...suppliers.map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
hint: `${s.sku_count ?? 0} 个货号${categoryHint(s.main_categories, 1) ? ` · ${categoryHint(s.main_categories, 1)}` : ''}`
|
||||
}))
|
||||
], [suppliers])
|
||||
|
||||
const [selected, setSelected] = useState(() => new Set())
|
||||
const [panel, setPanel] = useState(null)
|
||||
/* 抽屉里的编辑态(由 GroupDrawer 报上来),用来算历史层数 */
|
||||
const [groupMode, setGroupMode] = useState({ kind: 'view' })
|
||||
/* 本次挂载的令牌:用来认出上一次挂载残留在历史里的孤儿条目 */
|
||||
const [mountId] = useState(() => makeMountId())
|
||||
|
||||
/* 本页现在压了几层历史:抽屉 1 层,抽屉里的编辑态再加 1 层。
|
||||
刻意用「当前 UI 状态」算,而不是从 history.state 里读计数器 ——
|
||||
否则用户开着抽屉点侧栏跳走再回来,会读到上一次挂载留下的脏层数,
|
||||
关抽屉时多退几条,直接把人退出应用。 */
|
||||
const layerCount = panel ? (groupMode && groupMode.kind !== 'view' ? 2 : 1) : 0
|
||||
|
||||
/* 抽屉也挂进浏览器历史:侧键返回时先收抽屉,而不是直接跳走 */
|
||||
const openPanel = useCallback((next) => {
|
||||
pushLayer(mountId, LAYER_PANEL, next)
|
||||
setGroupMode({ kind: 'view' })
|
||||
setPanel(next)
|
||||
}, [mountId])
|
||||
|
||||
const closePanel = useCallback(() => {
|
||||
// 一次退掉本页所有层(抽屉 + 抽屉里的编辑态),回到进入产品页时那条历史
|
||||
if (layerCount > 0) popLayers(layerCount)
|
||||
else setPanel(null)
|
||||
setGroupMode({ kind: 'view' })
|
||||
}, [layerCount])
|
||||
|
||||
useLayerRestore(mountId, LAYER_PANEL, (snapshot) => {
|
||||
setPanel(snapshot && snapshot.kind ? snapshot : null)
|
||||
if (!snapshot || !snapshot.kind) setGroupMode({ kind: 'view' })
|
||||
}, '/products')
|
||||
|
||||
/* 挂载时擦掉当前历史条目上「上一次挂载」留下的浮层快照。
|
||||
场景:开着抽屉直接点侧栏跳走,抽屉随组件卸载消失,但压进历史的条目还在;
|
||||
回到本页时那条历史就是孤儿,留着会让 history.state 一直显示"抽屉开着",
|
||||
也让反复按返回时多逛几条无效历史。擦成干净条目即可(那些条目是我们自己 push 的,
|
||||
不带路由 state,清成 null 不影响 React Router)。 */
|
||||
useEffect(() => {
|
||||
const s = window.history.state
|
||||
if (s && typeof s.__tmsDepth === 'number' && s.__tmsMount !== mountId) {
|
||||
window.history.replaceState(null, '')
|
||||
}
|
||||
}, [mountId])
|
||||
|
||||
/* 关键词防抖 250ms */
|
||||
useEffect(() => {
|
||||
@@ -1193,12 +1580,16 @@ function ProductManagement() {
|
||||
<p className="page-sub">按货号、品名、规格或参数检索产品与采购价</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn" onClick={() => setPanel({ kind: 'cart' })}>
|
||||
<CartIcon size={16} /> 小推车{cartCount > 0 && <span className="cart-badge">{cartCount}</span>}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setPanel({ kind: 'new' })}>
|
||||
<PlusCircleIcon size={16} /> 新增产品
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button className="btn" onClick={() => openPanel({ kind: 'cart' })}>
|
||||
<CartIcon size={16} /> 小推车{cartCount > 0 && <span className="cart-badge">{cartCount}</span>}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button className="btn btn-primary" onClick={() => openPanel({ kind: 'new' })}>
|
||||
<PlusCircleIcon size={16} /> 新增产品
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1212,11 +1603,15 @@ function ProductManagement() {
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<select value={supplierFilter} onChange={(e) => setSupplierFilter(e.target.value)}>
|
||||
<option value="">全部供应商</option>
|
||||
<option value="none">未分类(无供应商)</option>
|
||||
{suppliers.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<SearchSelect
|
||||
className="toolbar-supplier-filter"
|
||||
value={supplierFilter}
|
||||
onChange={(v) => setSupplierFilter(v)}
|
||||
options={supplierFilterOptions}
|
||||
placeholder="全部供应商"
|
||||
searchPlaceholder="输入供应商名 / 主营类目搜索…"
|
||||
emptyText="没有匹配的供应商"
|
||||
/>
|
||||
<label className="check-inline">
|
||||
<input type="checkbox" checked={onlyLow} onChange={(e) => setOnlyLow(e.target.checked)} />
|
||||
只看待核对
|
||||
@@ -1232,8 +1627,8 @@ function ProductManagement() {
|
||||
<button className="btn btn-sm" onClick={invert}>反选</button>
|
||||
<button className="btn btn-sm" onClick={clearSel}>取消选择</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="btn btn-sm btn-primary" onClick={addToCart}>加入小推车</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={deleteSelected}>删除</button>
|
||||
{canEdit && <button className="btn btn-sm btn-primary" onClick={addToCart}>加入小推车</button>}
|
||||
{canEdit && <button className="btn btn-sm btn-danger" onClick={deleteSelected}>删除</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1282,7 +1677,7 @@ function ProductManagement() {
|
||||
<tr
|
||||
key={g.id}
|
||||
className={picked ? 'row-picked' : ''}
|
||||
onClick={() => setPanel({ kind: 'group', groupId: g.id, highlightSku: g.matched_items?.[0]?.sku || null })}
|
||||
onClick={() => openPanel({ kind: 'group', groupId: g.id, highlightSku: g.matched_items?.[0]?.sku || null })}
|
||||
>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
@@ -1319,24 +1714,36 @@ function ProductManagement() {
|
||||
|
||||
{panel?.kind === 'group' && (
|
||||
<GroupDrawer
|
||||
mountId={mountId}
|
||||
groupId={panel.groupId}
|
||||
highlightSku={panel.highlightSku}
|
||||
suppliers={suppliers}
|
||||
onClose={() => setPanel(null)}
|
||||
onClose={closePanel}
|
||||
onChanged={afterMutate}
|
||||
onModeChange={setGroupMode}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{panel?.kind === 'new' && (
|
||||
<NewProductDrawer
|
||||
suppliers={suppliers}
|
||||
onClose={() => setPanel(null)}
|
||||
onCreated={() => { setPanel(null); afterMutate() }}
|
||||
/>
|
||||
onClose={closePanel}
|
||||
canEdit={canEdit}
|
||||
onCreated={(info) => {
|
||||
closePanel()
|
||||
afterMutate()
|
||||
/* 撞号时后端自动落副本,明确告诉用户新建到手的到底是什么货号 */
|
||||
if (info?.duplicated_from) {
|
||||
setNoticeOk(true)
|
||||
setNotice(`货号 ${info.duplicated_from} 已存在,已自动建成副本「${info.display_sku}」,并记下上传者`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{panel?.kind === 'cart' && (
|
||||
<CartDrawer onClose={() => { setPanel(null); refreshCart() }} onChanged={refreshCart} />
|
||||
<CartDrawer onClose={() => { closePanel(); refreshCart() }} onChanged={refreshCart} canEdit={canEdit} canQuoteEdit={canQuoteEdit} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
SaveIcon, KeyIcon, CameraIcon, LogoutIcon, BuildingIcon, ShieldIcon,
|
||||
EyeIcon, EyeOffIcon, UserIcon
|
||||
} from '../components/Icons'
|
||||
import { useAuth, roleLabel } from '../auth'
|
||||
import { setPageTitle } from '../branding'
|
||||
import { setUnsavedGuard, clearUnsavedGuard } from '../unsavedGuard'
|
||||
|
||||
/* 把用户对象裁成表单结构 */
|
||||
function toForm(user) {
|
||||
return {
|
||||
display_name: user?.display_name || '',
|
||||
position: user?.position || '',
|
||||
email: user?.email || '',
|
||||
phone: user?.phone || '',
|
||||
address: user?.address || '',
|
||||
org_id: user?.org?.id ? String(user.org.id) : '',
|
||||
role: user?.role || 'sales_rep',
|
||||
avatar: user?.avatar || ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 头像压到 160×160 的 JPEG,直接存 dataURL —— 本地阶段省掉一套文件接口与存储 */
|
||||
function compressImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(new Error('读取图片失败'))
|
||||
reader.onload = () => {
|
||||
const image = new Image()
|
||||
image.onerror = () => reject(new Error('这个图片格式浏览器读不了'))
|
||||
image.onload = () => {
|
||||
const size = 160
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const context = canvas.getContext('2d')
|
||||
const scale = Math.max(size / image.width, size / image.height)
|
||||
const width = image.width * scale
|
||||
const height = image.height * scale
|
||||
context.drawImage(image, (size - width) / 2, (size - height) / 2, width, height)
|
||||
resolve(canvas.toDataURL('image/jpeg', 0.85))
|
||||
}
|
||||
image.src = reader.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
const { user, setUser, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [form, setForm] = useState(() => toForm(user))
|
||||
const [saved, setSaved] = useState(() => toForm(user))
|
||||
const [organizations, setOrganizations] = useState([])
|
||||
const [roles, setRoles] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const fileRef = useRef(null)
|
||||
|
||||
const [password, setPassword] = useState({ current: '', next: '', confirm: '' })
|
||||
const [passwordBusy, setPasswordBusy] = useState(false)
|
||||
const [passwordNotice, setPasswordNotice] = useState(null)
|
||||
const [reveal, setReveal] = useState({ current: false, next: false, confirm: false })
|
||||
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(form) !== JSON.stringify(saved),
|
||||
[form, saved]
|
||||
)
|
||||
|
||||
/* guard 要读到最新值,用 ref 兜住(注册只在挂载/卸载时做一次) */
|
||||
const dirtyRef = useRef(dirty)
|
||||
const formRef = useRef(form)
|
||||
const savedRef = useRef(saved)
|
||||
dirtyRef.current = dirty
|
||||
formRef.current = form
|
||||
savedRef.current = saved
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle('个人资料')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const [orgRes, roleRes] = await Promise.all([
|
||||
axios.get('/api/organizations'),
|
||||
axios.get('/api/roles')
|
||||
])
|
||||
if (!alive) return
|
||||
setOrganizations(orgRes.data.organizations || [])
|
||||
setRoles(roleRes.data.roles || [])
|
||||
} catch { /* 下拉拉不到不影响主流程 */ }
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
/* 保存动作抽成 callback,供页面按钮与「离开前保存」共用 */
|
||||
const submitSave = useCallback(async () => {
|
||||
const payload = formRef.current
|
||||
const response = await axios.patch('/api/me', {
|
||||
display_name: payload.display_name,
|
||||
position: payload.position,
|
||||
email: payload.email,
|
||||
phone: payload.phone,
|
||||
address: payload.address,
|
||||
avatar: payload.avatar,
|
||||
role: payload.role,
|
||||
org_id: payload.org_id === '' ? undefined : Number(payload.org_id)
|
||||
})
|
||||
const updated = response.data.user
|
||||
setUser(updated)
|
||||
setSaved(toForm(updated))
|
||||
return updated
|
||||
}, [setUser])
|
||||
|
||||
/* 让外壳在跳转前能问一句「要不要先保存」 */
|
||||
useEffect(() => {
|
||||
setUnsavedGuard({
|
||||
isDirty: () => dirtyRef.current,
|
||||
save: () => submitSave(),
|
||||
discard: () => setForm(savedRef.current)
|
||||
})
|
||||
return () => clearUnsavedGuard()
|
||||
}, [submitSave])
|
||||
|
||||
const save = async () => {
|
||||
if (!form.display_name.trim()) { setNotice({ type: 'error', text: '显示名不能为空' }); return }
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
await submitSave()
|
||||
setNotice({ type: 'ok', text: '个人资料已保存' })
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '保存失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pickAvatar = async event => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
if (!/^image\//.test(file.type)) { setNotice({ type: 'error', text: '请选择图片文件' }); return }
|
||||
try {
|
||||
const dataUrl = await compressImage(file)
|
||||
setForm(current => ({ ...current, avatar: dataUrl }))
|
||||
setNotice({ type: 'info', text: '头像已选择,记得点右上角「保存个人资料」' })
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
const submitPassword = async event => {
|
||||
event.preventDefault()
|
||||
if (!password.current) { setPasswordNotice({ type: 'error', text: '请输入当前密码' }); return }
|
||||
if (!password.next) { setPasswordNotice({ type: 'error', text: '请输入新密码' }); return }
|
||||
if (password.next.length < 6) { setPasswordNotice({ type: 'error', text: '新密码至少 6 位' }); return }
|
||||
if (password.next !== password.confirm) { setPasswordNotice({ type: 'error', text: '两次输入的新密码不一致' }); return }
|
||||
setPasswordBusy(true)
|
||||
setPasswordNotice(null)
|
||||
try {
|
||||
await axios.patch('/api/me/password', {
|
||||
current_password: password.current,
|
||||
new_password: password.next,
|
||||
confirm_password: password.confirm
|
||||
})
|
||||
setPassword({ current: '', next: '', confirm: '' })
|
||||
setPasswordNotice({ type: 'ok', text: '密码已修改,其它设备上的登录已失效' })
|
||||
} catch (error) {
|
||||
setPasswordNotice({ type: 'error', text: error?.response?.data?.error || '修改密码失败' })
|
||||
} finally {
|
||||
setPasswordBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
if (dirty) setForm(saved)
|
||||
await logout()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const setField = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
const currentRole = roles.find(item => item.value === form.role)
|
||||
const orgName = organizations.find(item => String(item.id) === String(form.org_id))?.name || user?.org?.name || '—'
|
||||
const avatarText = (form.display_name || user?.username || '?').slice(0, 1).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="page-container profile-page">
|
||||
<div className="profile-head">
|
||||
<div className="profile-head-left">
|
||||
<span className="profile-avatar">
|
||||
{form.avatar ? <img src={form.avatar} alt="" /> : avatarText}
|
||||
</span>
|
||||
<div className="profile-head-text">
|
||||
<h1>{form.display_name || user?.username}</h1>
|
||||
<p>{form.position || roleLabel(form.role)} · {orgName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-head-actions">
|
||||
{dirty && <span className="profile-dirty-hint">● 有未保存的改动</span>}
|
||||
<button type="button" className="btn btn-secondary" disabled={!dirty} onClick={() => { setForm(saved); setNotice(null) }}>
|
||||
放弃修改
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!dirty || busy} onClick={save}>
|
||||
<SaveIcon size={15} /> {busy ? '保存中…' : '保存个人资料'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p className={`profile-notice ${notice.type}`}>{notice.text}</p>
|
||||
)}
|
||||
|
||||
<div className="profile-grid">
|
||||
{/* ---------------- 基本资料 ---------------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title"><UserIcon size={15} /> 基本资料</div>
|
||||
|
||||
<div className="profile-avatar-row">
|
||||
<span className="profile-avatar profile-avatar-sm">
|
||||
{form.avatar ? <img src={form.avatar} alt="" /> : avatarText}
|
||||
</span>
|
||||
<div className="profile-avatar-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => fileRef.current?.click()}>
|
||||
<CameraIcon size={14} /> 更换头像
|
||||
</button>
|
||||
{form.avatar && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setField('avatar', '')}>
|
||||
移除
|
||||
</button>
|
||||
)}
|
||||
<p className="field-hint">支持 jpg / png,会自动裁成方形并压缩</p>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" hidden onChange={pickAvatar} />
|
||||
</div>
|
||||
|
||||
<div className="profile-fields">
|
||||
<div className="form-group">
|
||||
<label>显示名 <i className="required">*</i></label>
|
||||
<input type="text" value={form.display_name} onChange={event => setField('display_name', event.target.value)} placeholder="在系统里显示的名字" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>职位</label>
|
||||
<input type="text" value={form.position} onChange={event => setField('position', event.target.value)} placeholder="如:外贸业务主管" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>联系邮箱</label>
|
||||
<input type="email" value={form.email} onChange={event => setField('email', event.target.value)} placeholder="[email protected]" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>电话</label>
|
||||
<input type="text" value={form.phone} onChange={event => setField('phone', event.target.value)} placeholder="手机或座机" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label><BuildingIcon size={13} /> 所属组织</label>
|
||||
<select value={form.org_id} onChange={event => setField('org_id', event.target.value)}>
|
||||
<option value="">未归属</option>
|
||||
{organizations.map(org => (
|
||||
<option key={org.id} value={org.id}>{org.name}{org.member_count ? `(${org.member_count} 人)` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label><ShieldIcon size={13} /> 权限角色</label>
|
||||
<select value={form.role} onChange={event => setField('role', event.target.value)}>
|
||||
{roles.map(role => (
|
||||
<option key={role.value} value={role.value}>{role.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="field-hint">
|
||||
角色本身不带权限,它决定你进入哪个权限组 →
|
||||
<b> {currentRole?.group_name || '尚未指派权限组'}</b>
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-group profile-field-full">
|
||||
<label>地址</label>
|
||||
<textarea rows="2" value={form.address} onChange={event => setField('address', event.target.value)} placeholder="公司或办公地址" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-meta">
|
||||
<span>账号:<b>{user?.username}</b></span>
|
||||
<span>权限组:<b>{user?.permission_group?.name || '—'}</b></span>
|
||||
<span>可访问权限点:<b>{user?.permissions?.length || 0}</b></span>
|
||||
<span>上次登录:<b>{user?.last_login_at ? new Date(user.last_login_at).toLocaleString('zh-CN') : '—'}</b></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------------- 修改密码 ---------------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title"><KeyIcon size={15} /> 修改密码</div>
|
||||
<form className="profile-fields profile-fields-single" onSubmit={submitPassword}>
|
||||
{[
|
||||
['current', '当前密码', '请输入当前密码'],
|
||||
['next', '新密码', '至少 6 位'],
|
||||
['confirm', '再次输入新密码', '与上面保持一致']
|
||||
].map(([key, label, placeholder]) => (
|
||||
<div className="form-group" key={key}>
|
||||
<label>{label}</label>
|
||||
<div className="password-input">
|
||||
<input
|
||||
type={reveal[key] ? 'text' : 'password'}
|
||||
autoComplete={key === 'current' ? 'current-password' : 'new-password'}
|
||||
value={password[key]}
|
||||
onChange={event => setPassword(current => ({ ...current, [key]: event.target.value }))}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-reveal"
|
||||
onClick={() => setReveal(current => ({ ...current, [key]: !current[key] }))}
|
||||
title={reveal[key] ? '隐藏' : '显示'}
|
||||
aria-label={reveal[key] ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
{reveal[key] ? <EyeOffIcon size={15} /> : <EyeIcon size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{passwordNotice && <p className={`profile-notice ${passwordNotice.type}`}>{passwordNotice.text}</p>}
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={passwordBusy}>
|
||||
<KeyIcon size={15} /> {passwordBusy ? '提交中…' : '确认修改密码'}
|
||||
</button>
|
||||
<p className="field-hint">改密码需要当前密码验证;修改成功后其它设备上的登录会被强制退出。</p>
|
||||
</form>
|
||||
|
||||
<div className="profile-signout">
|
||||
<button type="button" className="btn btn-secondary" onClick={signOut}>
|
||||
<LogoutIcon size={15} /> 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfilePage
|
||||
@@ -1,108 +1,242 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
TargetIcon, RefreshIcon, UsersIcon, PlusCircleIcon, SearchIcon, ArrowLeftIcon
|
||||
} from '../components/Icons'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { setPageTitle } from '../branding'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'pending', label: '未成交', hint: '还在谈的客户' },
|
||||
{ key: 'won', label: '已成交', hint: '已经下单的客户' },
|
||||
{ key: 'lost', label: '已流失', hint: '明确放弃或长期无响应的客户' },
|
||||
{ key: 'all', label: '全部', hint: '不分成败的所有开发客户' }
|
||||
]
|
||||
|
||||
const DEAL_LABELS = { pending: '未成交', won: '已成交', lost: '已流失' }
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10)
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function ProspectManagement() {
|
||||
const [formData, setFormData] = useState({
|
||||
productKeywords: '',
|
||||
countries: '',
|
||||
customerTypes: '',
|
||||
excludeKeywords: '',
|
||||
resultLimit: 100
|
||||
})
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState('pending')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [items, setItems] = useState([])
|
||||
const [stats, setStats] = useState({ pending: 0, won: 0, lost: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
alert('自动获客功能开发中...')
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/prospects', {
|
||||
params: { deal_status: tab === 'all' ? '' : tab, q: keyword.trim() }
|
||||
})
|
||||
setItems(data.items || [])
|
||||
setStats(data.stats || { pending: 0, won: 0, lost: 0 })
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '加载开发客户失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [tab, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle('新客开发')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(load, keyword ? 260 : 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [load, keyword])
|
||||
|
||||
/* 标记成交/流失:乐观更新 —— 先按新状态改界面(不匹配当前标签页的行直接消失、
|
||||
顶部计数同步增减),后台失败再把行和计数整体放回原状。 */
|
||||
const mark = async (item, dealStatus) => {
|
||||
setBusyId(item.id)
|
||||
const previous = item.deal_status
|
||||
const index = items.findIndex(row => row.id === item.id)
|
||||
const nextStats = { ...stats }
|
||||
if (previous && nextStats[previous] !== undefined) {
|
||||
nextStats[previous] = Math.max(0, (nextStats[previous] || 0) - 1)
|
||||
}
|
||||
nextStats[dealStatus] = (nextStats[dealStatus] || 0) + 1
|
||||
setStats(nextStats)
|
||||
if (tab !== 'all' && dealStatus !== tab) {
|
||||
setItems(prev => prev.filter(row => row.id !== item.id))
|
||||
} else {
|
||||
setItems(prev => prev.map(row => (row.id === item.id ? { ...row, deal_status: dealStatus } : row)))
|
||||
}
|
||||
setNotice({ type: 'ok', text: `「${item.company}」已标记为${DEAL_LABELS[dealStatus]}` })
|
||||
try {
|
||||
await axios.patch(`/api/prospects/${item.id}`, { deal_status: dealStatus })
|
||||
} catch (error) {
|
||||
setStats(stats)
|
||||
setItems(prev => {
|
||||
if (prev.some(row => row.id === item.id)) return prev.map(row => (row.id === item.id ? item : row))
|
||||
const next = [...prev]
|
||||
next.splice(Math.min(Math.max(index, 0), next.length), 0, item)
|
||||
return next
|
||||
})
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '标记失败,已恢复原状态' })
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const openCreate = () => navigate('/customers')
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-container prospect-page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">新客开发(自动获客)</h1>
|
||||
<div>
|
||||
<h1 className="page-title">新客开发</h1>
|
||||
<p className="page-subtitle">
|
||||
上半部分是自动获客线索池(待上线);下半部分管理开发结果 —— 谈成的进「已成交」,谈不动的进「已流失」,其余留在「未成交」继续跟。
|
||||
和「我的客户」同一份数据、同一套归属:只显示你自己名下的客户。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={openCreate}>
|
||||
<PlusCircleIcon size={15} /> 手工新增客户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="prospect-container">
|
||||
{/* 搜索条件 */}
|
||||
<div className="card">
|
||||
<h2>搜索条件</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>产品关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:LED灯具, 五金工具"
|
||||
value={formData.productKeywords}
|
||||
onChange={e => setFormData({...formData, productKeywords: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
{/* ---------------- 自动获客(线索池,待上线) ---------------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title"><TargetIcon size={15} /> 自动获客线索池</div>
|
||||
<div className="prospect-pool-note">
|
||||
<p>
|
||||
线索池将按「产品关键词 + 目标国家 + 客户类型」从公开数据源(GLEIF、Companies House、搜索引擎)批量抓取,
|
||||
再由 AI 做去重与意向评分,合格线索一键转成客户。
|
||||
</p>
|
||||
<span className="pill pill-warn">待上线</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="form-group">
|
||||
<label>目标国家</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:德国, 英国, 美国"
|
||||
value={formData.countries}
|
||||
onChange={e => setFormData({...formData, countries: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
{/* ---------------- 开发结果管理 ---------------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title"><UsersIcon size={15} /> 开发结果管理</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>客户类型</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:经销商, 系统集成商, OEM"
|
||||
value={formData.customerTypes}
|
||||
onChange={e => setFormData({...formData, customerTypes: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>排除关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:招聘, 二手, 学校"
|
||||
value={formData.excludeKeywords}
|
||||
onChange={e => setFormData({...formData, excludeKeywords: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>结果数量</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.resultLimit}
|
||||
onChange={e => setFormData({...formData, resultLimit: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
开始搜索
|
||||
</button>
|
||||
</form>
|
||||
<div className="prospect-stats">
|
||||
<button type="button" className={`prospect-stat${tab === 'pending' ? ' active' : ''}`} onClick={() => setTab('pending')}>
|
||||
<b>{stats.pending}</b>
|
||||
<span>未成交</span>
|
||||
</button>
|
||||
<button type="button" className={`prospect-stat${tab === 'won' ? ' active' : ''}`} onClick={() => setTab('won')}>
|
||||
<b className="is-won">{stats.won}</b>
|
||||
<span>已成交</span>
|
||||
</button>
|
||||
<button type="button" className={`prospect-stat${tab === 'lost' ? ' active' : ''}`} onClick={() => setTab('lost')}>
|
||||
<b className="is-lost">{stats.lost}</b>
|
||||
<span>已流失</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果 */}
|
||||
<div className="card">
|
||||
<h2>搜索结果</h2>
|
||||
<div className="empty-state">
|
||||
请配置搜索条件并点击"开始搜索"
|
||||
<div className="prospect-toolbar">
|
||||
<div className="prospect-tabs" role="tablist">
|
||||
{TABS.map(item => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === item.key}
|
||||
className={`prospect-tab${tab === item.key ? ' active' : ''}`}
|
||||
title={item.hint}
|
||||
onClick={() => setTab(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="prospect-search">
|
||||
<SearchIcon size={14} />
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
placeholder="搜索公司 / 国家 / 邮箱"
|
||||
aria-label="搜索开发客户"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={load} disabled={loading}>
|
||||
<RefreshIcon size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<h3>功能说明</h3>
|
||||
<p>自动获客功能将帮助你从多个数据源自动搜索潜在客户:</p>
|
||||
<ul>
|
||||
<li>公开公司注册数据(GLEIF、Companies House)</li>
|
||||
<li>网页搜索引擎</li>
|
||||
<li>AI 智能解析和评分</li>
|
||||
<li>自动去重和质量过滤</li>
|
||||
</ul>
|
||||
<p><strong>此功能将在后续版本中完善。</strong></p>
|
||||
</div>
|
||||
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
|
||||
|
||||
<div className="data-table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公司</th>
|
||||
<th>国家</th>
|
||||
<th className="num">联系人</th>
|
||||
<th className="num">报价单</th>
|
||||
<th>最后跟进</th>
|
||||
<th>成交状态</th>
|
||||
<th className="num">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<SkeletonTableRows rows={5} widths={[2.2, 0.8, 0.6, 0.6, 1, 1, 1.4]} />
|
||||
) : items.length === 0 ? (
|
||||
<tr><td colSpan="7" className="empty-state">
|
||||
{keyword ? '没有匹配的客户' : `「${TABS.find(item => item.key === tab)?.label}」下暂无客户`}
|
||||
</td></tr>
|
||||
) : items.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<strong>{item.company}</strong>
|
||||
{item.company_en ? <div className="muted-note">{item.company_en}</div> : null}
|
||||
</td>
|
||||
<td>{item.country || '—'}</td>
|
||||
<td className="num">{item.contact_count || 0}</td>
|
||||
<td className="num">{item.quote_count || 0}</td>
|
||||
<td>{formatDate(item.last_follow_at)}</td>
|
||||
<td>
|
||||
<span className={`pill ${item.deal_status === 'won' ? 'pill-ok' : item.deal_status === 'lost' ? 'pill-danger' : 'pill-mute'}`}>
|
||||
{DEAL_LABELS[item.deal_status] || '未成交'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
<div className="prospect-actions">
|
||||
{item.deal_status !== 'won' && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'won')}>
|
||||
标记成交
|
||||
</button>
|
||||
)}
|
||||
{item.deal_status !== 'lost' && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'lost')}>
|
||||
标记流失
|
||||
</button>
|
||||
)}
|
||||
{item.deal_status !== 'pending' && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'pending')}>
|
||||
退回跟进
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => navigate(`/customers/${item.id}`)}>
|
||||
<ArrowLeftIcon size={13} /> 查看
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProspectManagement
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
/**
|
||||
* 采购单管理
|
||||
* 数据来源:happy 后端 /api/purchase-orders(P1 后端合并阶段开放)
|
||||
*/
|
||||
function PurchaseOrderManagement() {
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'purchase.edit')
|
||||
const [orders, setOrders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
@@ -31,7 +35,7 @@ function PurchaseOrderManagement() {
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">采购单管理</h1>
|
||||
<button className="btn btn-primary">+ 新建采购单</button>
|
||||
{canEdit && <button className="btn btn-primary">+ 新建采购单</button>}
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
@@ -62,7 +66,7 @@ function PurchaseOrderManagement() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
@@ -81,7 +85,7 @@ function PurchaseOrderManagement() {
|
||||
<td><span className={`status-badge status-${o.status}`}>{statusText(o.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
{canEdit && <button className="btn-link">编辑</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,807 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeftIcon, CartIcon, CheckIcon, CloseIcon, PlusCircleIcon, QuoteIcon, SearchIcon, UsersIcon
|
||||
} from '../components/Icons'
|
||||
import { buildCurrencyOptions, crossRate, currencyLabel, formatRate, parseAmount } from '../currencies'
|
||||
import { SkuThumb } from '../components/SkuThumb'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
/**
|
||||
* 汇总报价 —— 把小推车里的货号变成一张正式报价单。
|
||||
*
|
||||
* 页面顺序刻意做成「先客户、后货、再条款」:
|
||||
* 报价单是发给客户的凭证,抬头必须先立住(公司、地址、联系方式),
|
||||
* 后面所有条款都是围绕这个抬头展开的。
|
||||
*
|
||||
* 单价口径与询盘报价完全一致:采购价 ÷ 汇率 × (1 + 加价率),
|
||||
* 汇率是「1 报价币 = ? 采购币(CNY)」。不要在这里另立一套算法。
|
||||
*/
|
||||
|
||||
const API = '/api'
|
||||
const SKU_API = '/api/sku'
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
/** 从某个日期键(YYYY-MM-DD)往后推 n 天 */
|
||||
function addDays(dateKey, days) {
|
||||
const base = Date.parse(dateKey)
|
||||
if (!Number.isFinite(base)) return ''
|
||||
return new Date(base + Number(days) * 86400000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/* 条款选项 —— 都是外贸报价里最常用的几种,不做成自由填空 */
|
||||
const PAYMENT_METHODS = ['T/T', 'L/C', 'D/P', 'D/A', 'O/A', 'Western Union', 'PayPal']
|
||||
const DEPOSIT_OPTIONS = [30, 50, 70, 100]
|
||||
const DELIVERY_METHODS = ['EXW', 'FOB', 'CIF', 'CFR', 'DDP', 'DAP', 'FCA']
|
||||
function money(value, digits = 2) {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return '—'
|
||||
return num.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 客户搜索选择 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function CustomerPicker({ customers, selectedId, onSelect, onCreateNew, canEditCustomer }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const boxRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { setKeyword(''); return }
|
||||
const timer = window.setTimeout(() => inputRef.current?.focus(), 0)
|
||||
const onPointerDown = event => {
|
||||
if (!boxRef.current?.contains(event.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
return () => { window.clearTimeout(timer); document.removeEventListener('mousedown', onPointerDown) }
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase()
|
||||
if (!term) return customers.slice(0, 60)
|
||||
return customers
|
||||
.filter(c => [c.company, c.company_en, c.country, c.email, c.phone].filter(Boolean).join(' ').toLowerCase().includes(term))
|
||||
.slice(0, 60)
|
||||
}, [customers, keyword])
|
||||
|
||||
const selected = customers.find(c => c.id === selectedId)
|
||||
|
||||
return (
|
||||
<div className="quote-customer-picker" ref={boxRef}>
|
||||
<button type="button" className={`quote-customer-btn${open ? ' is-open' : ''}`} onClick={() => setOpen(v => !v)}>
|
||||
<UsersIcon size={17} />
|
||||
<span className="quote-customer-btn-text">
|
||||
{selected
|
||||
? <><b>{selected.company}</b><em>{[selected.country, selected.email].filter(Boolean).join(' · ') || '未填联系方式'}</em></>
|
||||
: '选择客户'}
|
||||
</span>
|
||||
<span className="quote-customer-btn-hint">搜索 / 更换</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="quote-customer-pop">
|
||||
<div className="quote-customer-search">
|
||||
<SearchIcon size={15} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
placeholder="搜索公司 / 国家 / 邮箱"
|
||||
aria-label="搜索客户"
|
||||
/>
|
||||
</div>
|
||||
<div className="quote-customer-list">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="quote-customer-empty">
|
||||
<p>{customers.length === 0 ? '还没有客户' : '没有匹配的客户'}</p>
|
||||
{canEditCustomer && (
|
||||
<button type="button" className="btn btn-sm btn-primary" onClick={() => { setOpen(false); onCreateNew(keyword.trim()) }}>
|
||||
<PlusCircleIcon size={14} /> 新建客户
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filtered.map(c => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.id}
|
||||
className={`quote-customer-item${c.id === selectedId ? ' active' : ''}`}
|
||||
onClick={() => { onSelect(c.id); setOpen(false) }}
|
||||
>
|
||||
<b>{c.company}</b>
|
||||
<span>{[c.country, c.email, c.phone].filter(Boolean).join(' · ') || '未填联系方式'}</span>
|
||||
</button>
|
||||
))}
|
||||
{canEditCustomer && (
|
||||
<button type="button" className="quote-customer-new" onClick={() => { setOpen(false); onCreateNew(keyword.trim()) }}>
|
||||
<PlusCircleIcon size={14} /> 找不到?新建客户
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 客户抬头(自动带出地址与联系方式) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function CustomerCard({ customer, contacts, onChangeCustomer }) {
|
||||
if (!customer) return null
|
||||
const primary = contacts?.[0]
|
||||
const rows = [
|
||||
['公司名称', customer.company],
|
||||
['英文名称', customer.company_en],
|
||||
['国家 / 地区', customer.country],
|
||||
['地址', customer.address],
|
||||
['邮箱', customer.email],
|
||||
['电话', customer.phone],
|
||||
['联系人', primary ? [primary.name, primary.position].filter(Boolean).join(' · ') : null],
|
||||
['联系人邮箱', primary?.email],
|
||||
['联系人电话', primary?.phone]
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="quote-head">
|
||||
<div className="quote-head-top">
|
||||
<div>
|
||||
<span className="quote-head-company">{customer.company}</span>
|
||||
{customer.company_en && <span className="quote-head-company-en">{customer.company_en}</span>}
|
||||
</div>
|
||||
<div className="quote-head-actions">
|
||||
{customer.status !== 'active' && <span className="pill pill-warn">未启用</span>}
|
||||
<button type="button" className="btn btn-sm" onClick={onChangeCustomer}>更换客户</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="quote-head-grid">
|
||||
{rows.map(([label, value]) => (
|
||||
<div className="quote-head-cell" key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd className={value ? '' : 'is-empty'}>{value || '未填写'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="quote-head-note">以上信息取自客户档案,开单时会一并写入报价单抬头。</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 新建客户 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function NewCustomerModal({ initialCompany, onClose, onCreated, canEditCustomer }) {
|
||||
const [form, setForm] = useState({
|
||||
company: initialCompany || '',
|
||||
company_en: '',
|
||||
country: '',
|
||||
address: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
contact_name: '',
|
||||
contact_position: '',
|
||||
notes: ''
|
||||
})
|
||||
const [saving, setSaving] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
|
||||
/**
|
||||
* @param {'active'|'inactive'} status
|
||||
* active = 保存并启用(立刻可作为正式客户继续跟进)
|
||||
* inactive = 仅保存(先建档,还没确认要不要投入跟进)
|
||||
*/
|
||||
const save = async status => {
|
||||
if (!form.company.trim()) { setError('公司名称不能为空'); return }
|
||||
setError('')
|
||||
setSaving(status)
|
||||
try {
|
||||
const { data } = await axios.post(`${API}/customers`, {
|
||||
company: form.company.trim(),
|
||||
company_en: form.company_en.trim() || null,
|
||||
country: form.country.trim() || null,
|
||||
address: form.address.trim() || null,
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
source: '报价单',
|
||||
status
|
||||
})
|
||||
/* 填了联系人姓名就顺手建一条主联系人 —— 报价单抬头要用 */
|
||||
if (form.contact_name.trim()) {
|
||||
try {
|
||||
await axios.post(`${API}/customers/${data.id}/contacts`, {
|
||||
name: form.contact_name.trim(),
|
||||
position: form.contact_position.trim() || null,
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
is_primary: true
|
||||
})
|
||||
} catch { /* 联系人建失败不该拦住客户本身 */ }
|
||||
}
|
||||
onCreated(data.id)
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || err.message)
|
||||
setSaving('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>新建客户</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose}><CloseIcon size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && <div className="notice-inline">{error}</div>}
|
||||
<div className="form-group">
|
||||
<label>公司名称 *</label>
|
||||
<input type="text" value={form.company} onChange={e => set('company', e.target.value)} placeholder="客户公司全称" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>英文名称</label>
|
||||
<input type="text" value={form.company_en} onChange={e => set('company_en', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>国家 / 地区</label>
|
||||
<input type="text" value={form.country} onChange={e => set('country', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>地址</label>
|
||||
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} placeholder="用于报价单抬头" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>邮箱</label>
|
||||
<input type="email" value={form.email} onChange={e => set('email', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>电话</label>
|
||||
<input type="text" value={form.phone} onChange={e => set('phone', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>联系人</label>
|
||||
<input type="text" value={form.contact_name} onChange={e => set('contact_name', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>联系人职务</label>
|
||||
<input type="text" value={form.contact_position} onChange={e => set('contact_position', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea rows="2" value={form.notes} onChange={e => set('notes', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onClose}>取消</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
{canEditCustomer && (
|
||||
<button type="button" className="btn" onClick={() => save('inactive')} disabled={!!saving}>
|
||||
{saving === 'inactive' ? '保存中…' : '仅保存'}
|
||||
</button>
|
||||
)}
|
||||
{canEditCustomer && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => save('active')} disabled={!!saving}>
|
||||
{saving === 'active' ? '保存中…' : '保存并启用'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主页面 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function QuoteEditor() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const canEditQuote = hasPermission(user, 'quote.edit')
|
||||
const canEditCustomer = hasPermission(user, 'customer.edit')
|
||||
|
||||
const [cart, setCart] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [contacts, setContacts] = useState([])
|
||||
const [customerId, setCustomerId] = useState(null)
|
||||
const [selectedCustomer, setSelectedCustomer] = useState(null)
|
||||
const [rates, setRates] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [newCustomerOpen, setNewCustomerOpen] = useState(false)
|
||||
const [newCustomerSeed, setNewCustomerSeed] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
const [header, setHeader] = useState({
|
||||
pi_no: '',
|
||||
quote_date: today(),
|
||||
/* 有效期以「距报价日期多少天」为准,截止日期由它推出来 */
|
||||
validity_days: '30',
|
||||
currency: 'USD',
|
||||
exchange_rate: '',
|
||||
markup_rate: '25',
|
||||
payment_method: 'T/T',
|
||||
deposit_percent: '30',
|
||||
delivery_method: 'FOB',
|
||||
delivery_port: 'Shanghai',
|
||||
order_notes: '',
|
||||
internal_notes: ''
|
||||
})
|
||||
const [qtyOverride, setQtyOverride] = useState({})
|
||||
|
||||
const setField = (key, value) => setHeader(current => ({ ...current, [key]: value }))
|
||||
|
||||
/* ---------- 初始数据:小推车 + 客户 + 汇率 ---------- */
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const [cartRes, customerRes, fxRes] = await Promise.all([
|
||||
axios.get(`${SKU_API}/cart`),
|
||||
axios.get(`${API}/customers`),
|
||||
axios.get(`${API}/fx/rates`).catch(() => null)
|
||||
])
|
||||
if (!alive) return
|
||||
setCart(Array.isArray(cartRes.data) ? cartRes.data : [])
|
||||
setCustomers(Array.isArray(customerRes.data) ? customerRes.data : [])
|
||||
if (fxRes?.data?.ok && fxRes.data.rates) setRates(fxRes.data.rates)
|
||||
} catch (err) {
|
||||
if (alive) setError(err?.response?.data?.error || `加载数据失败:${err.message}`)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
/* ---------- 换了报价币种就把汇率换成实时值 ---------- */
|
||||
useEffect(() => {
|
||||
if (!rates || !header.currency) return
|
||||
const rate = crossRate(rates, header.currency, 'CNY')
|
||||
if (!rate) return
|
||||
setHeader(current => ({ ...current, exchange_rate: String(Number(rate.toFixed(4))) }))
|
||||
}, [rates, header.currency])
|
||||
|
||||
/* ---------- 选中客户后拉它的地址 / 联系人 ---------- */
|
||||
const loadCustomer = useCallback(async id => {
|
||||
setCustomerId(id)
|
||||
try {
|
||||
const { data } = await axios.get(`${API}/customers/${id}`)
|
||||
setSelectedCustomer(data.customer)
|
||||
setContacts(data.contacts || [])
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || err.message)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ---------- 明细与单价 ---------- */
|
||||
const priceOf = useCallback(cost => {
|
||||
const rate = parseAmount(header.exchange_rate)
|
||||
const markup = parseAmount(header.markup_rate)
|
||||
const value = Number(cost)
|
||||
if (!Number.isFinite(value) || value <= 0 || !rate || rate <= 0) return null
|
||||
const factor = Number.isFinite(markup) ? markup / 100 : 0
|
||||
return value / rate * (1 + factor)
|
||||
}, [header.exchange_rate, header.markup_rate])
|
||||
|
||||
const lines = useMemo(() => cart.map(row => {
|
||||
const cost = row.price == null ? null : Number(row.price)
|
||||
const rawQty = qtyOverride[row.sku] !== undefined ? qtyOverride[row.sku] : row.qty
|
||||
const qty = Number(rawQty) || 0
|
||||
const unit = priceOf(cost)
|
||||
return {
|
||||
key: row.sku,
|
||||
sku: row.display_sku || row.sku,
|
||||
rawSku: row.sku,
|
||||
/* 小推车接口带主图文件名,直接带到明细里给缩略图用 */
|
||||
image: row.image || null,
|
||||
product_name: row.product_name || row.display_sku || row.sku,
|
||||
spec: row.spec || null,
|
||||
cost,
|
||||
qty,
|
||||
unit,
|
||||
total: unit != null && qty > 0 ? unit * qty : null
|
||||
}
|
||||
}), [cart, priceOf, qtyOverride])
|
||||
|
||||
const grandTotal = lines.reduce((sum, line) => sum + (line.total || 0), 0)
|
||||
const missingQty = lines.filter(line => !line.qty).length
|
||||
const missingPrice = lines.filter(line => line.unit == null).length
|
||||
|
||||
/* 有效期:天数 → 截止日期。天数给人改,日期给人看,提交时两个都带上 */
|
||||
const validUntil = useMemo(
|
||||
() => addDays(header.quote_date, header.validity_days || 0),
|
||||
[header.quote_date, header.validity_days]
|
||||
)
|
||||
|
||||
/* 分期付款:只有 T/T 走「预付 + 尾款」这套,尾款比例自动补足到 100% */
|
||||
const isTT = header.payment_method === 'T/T'
|
||||
const depositPercent = Number(header.deposit_percent)
|
||||
const splitPayment = isTT && Number.isFinite(depositPercent) && depositPercent > 0 && depositPercent <= 100
|
||||
const balancePercent = splitPayment ? Number((100 - depositPercent).toFixed(4)) : null
|
||||
const depositAmount = splitPayment ? grandTotal * depositPercent / 100 : null
|
||||
const balanceAmount = splitPayment ? grandTotal * (balancePercent || 0) / 100 : null
|
||||
|
||||
const currencyOptions = useMemo(
|
||||
() => buildCurrencyOptions(rates, ['USD', 'CNY', 'EUR', 'GBP', 'HKD', 'JPY']),
|
||||
[rates]
|
||||
)
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
setNotice('')
|
||||
if (!customerId) { setError('请先在顶部选择客户'); return }
|
||||
if (!lines.length) { setError('小推车是空的,先到产品管理里加入货号'); return }
|
||||
if (missingQty) { setError(`还有 ${missingQty} 行没填数量`); return }
|
||||
if (missingPrice) { setError('有明细算不出单价,请检查汇率与加价率是否填齐'); return }
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const { data } = await axios.post(`${API}/quotes`, {
|
||||
customer_id: customerId,
|
||||
currency: header.currency,
|
||||
quote_date: header.quote_date,
|
||||
valid_until: validUntil || null,
|
||||
validity_days: Number(header.validity_days) || null,
|
||||
pi_no: header.pi_no || null,
|
||||
payment_method: header.payment_method || null,
|
||||
deposit_percent: splitPayment ? depositPercent : null,
|
||||
balance_percent: splitPayment ? balancePercent : null,
|
||||
delivery_method: header.delivery_method || null,
|
||||
delivery_port: header.delivery_port || null,
|
||||
notes: header.order_notes || null,
|
||||
internal_notes: header.internal_notes || null,
|
||||
status: 'draft',
|
||||
items: lines.map(line => ({
|
||||
sku: line.rawSku,
|
||||
product_name: line.product_name,
|
||||
spec: line.spec,
|
||||
qty: line.qty,
|
||||
unit_price: Number(line.unit.toFixed(4)),
|
||||
purchase_price: line.cost,
|
||||
exchange_rate: parseAmount(header.exchange_rate),
|
||||
markup_rate: (parseAmount(header.markup_rate) || 0) / 100
|
||||
}))
|
||||
})
|
||||
setNotice(`报价单 ${data.quote.quote_no} 已保存,正在跳转到报价单列表…`)
|
||||
window.setTimeout(() => navigate('/quotes'), 1100)
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || err.message)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="page-container"><div className="loading"><span className="spinner" /> 正在读取小推车与客户…</div></div>
|
||||
|
||||
return (
|
||||
<div className="page-container quote-editor">
|
||||
<div className="page-header quote-editor-header">
|
||||
<div className="quote-editor-title">
|
||||
<button type="button" className="btn btn-sm btn-icon" onClick={() => navigate(-1)} title="返回">
|
||||
<ArrowLeftIcon size={16} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="page-title">汇总报价</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="quote-editor-actions">
|
||||
<span className="secondary">{lines.length} 个货号</span>
|
||||
{canEditQuote && (
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
|
||||
<CheckIcon size={15} /> {saving ? '保存中…' : '保存报价单'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="notice-inline">{error}</div>}
|
||||
{notice && <div className="notice-inline notice-ok">{notice}</div>}
|
||||
|
||||
{/* ---------- ① 客户抬头 ---------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title">客户</div>
|
||||
{selectedCustomer ? (
|
||||
<CustomerCard
|
||||
customer={selectedCustomer}
|
||||
contacts={contacts}
|
||||
onChangeCustomer={() => { setSelectedCustomer(null); setCustomerId(null); setContacts([]) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="quote-customer-empty-state">
|
||||
<p>报价单要发给谁?先选一个客户 —— 选中后会自动带出地址与联系方式。</p>
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
selectedId={customerId}
|
||||
onSelect={loadCustomer}
|
||||
onCreateNew={seed => { setNewCustomerSeed(seed); setNewCustomerOpen(true) }}
|
||||
canEditCustomer={canEditCustomer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---------- ② 报价条件 ---------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title">报价条件</div>
|
||||
<div className="quote-terms-grid">
|
||||
<div className="form-group">
|
||||
<label>PI 号</label>
|
||||
<input
|
||||
type="text"
|
||||
value={header.pi_no}
|
||||
onChange={e => setField('pi_no', e.target.value)}
|
||||
placeholder="选填,例如 PI-20260914-01"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>报价日期</label>
|
||||
<input type="date" value={header.quote_date} onChange={e => setField('quote_date', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>报价有效期(距报价日期天数)</label>
|
||||
<input
|
||||
type="number" min="1" step="1"
|
||||
value={header.validity_days}
|
||||
onChange={e => setField('validity_days', e.target.value)}
|
||||
placeholder="例如 30"
|
||||
/>
|
||||
<span className="field-hint">
|
||||
{validUntil
|
||||
? `自 ${header.quote_date} 起 ${header.validity_days} 天,有效期至 ${validUntil}`
|
||||
: '填一个大于 0 的天数'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>报价币种</label>
|
||||
<select value={header.currency} onChange={e => setField('currency', e.target.value)}>
|
||||
{currencyOptions.map(option => (
|
||||
<option key={option.code} value={option.code}>{currencyLabel(option.code)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>汇率(1 {header.currency} = ? CNY)</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={header.exchange_rate}
|
||||
onChange={e => setField('exchange_rate', e.target.value)}
|
||||
placeholder="自动取实时汇率"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>加价率(%)</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={header.markup_rate}
|
||||
onChange={e => setField('markup_rate', e.target.value)}
|
||||
placeholder="例如 25"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>付款方式</label>
|
||||
<select value={header.payment_method} onChange={e => setField('payment_method', e.target.value)}>
|
||||
{PAYMENT_METHODS.map(method => <option key={method} value={method}>{method}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>交货方式</label>
|
||||
<div className="field-inline">
|
||||
<select value={header.delivery_method} onChange={e => setField('delivery_method', e.target.value)}>
|
||||
{DELIVERY_METHODS.map(method => <option key={method} value={method}>{method}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={header.delivery_port}
|
||||
onChange={e => setField('delivery_port', e.target.value)}
|
||||
placeholder="港口 / 地点,例如 Shanghai"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选 T/T 才展开分期:预付款点一下定比例,尾款百分比与金额自动算出 */}
|
||||
{isTT && (
|
||||
<div className="payment-split">
|
||||
<div className="payment-split-head">
|
||||
<span>预付款比例</span>
|
||||
</div>
|
||||
<div className="payment-split-options">
|
||||
{DEPOSIT_OPTIONS.map(percent => (
|
||||
<button
|
||||
key={percent}
|
||||
type="button"
|
||||
className={`payment-split-btn${depositPercent === percent ? ' active' : ''}`}
|
||||
onClick={() => setField('deposit_percent', String(percent))}
|
||||
>
|
||||
{percent}%
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="number" min="1" max="100" step="1"
|
||||
className="payment-split-input"
|
||||
value={header.deposit_percent}
|
||||
onChange={e => setField('deposit_percent', e.target.value)}
|
||||
aria-label="自定义预付款比例"
|
||||
/>
|
||||
<span className="payment-split-unit">% 自定义</span>
|
||||
</div>
|
||||
<div className="payment-split-result">
|
||||
{splitPayment ? (
|
||||
<>
|
||||
<span>预付款 <b>{depositPercent}%</b><em>{money(depositAmount)} {header.currency}</em></span>
|
||||
<span>尾款 <b>{balancePercent}%</b><em>{money(balanceAmount)} {header.currency}</em></span>
|
||||
{balancePercent === 0 && <span className="payment-split-note">全额预付,无尾款</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="quote-warn">预付款比例请填 1–100 之间的数字</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="quote-formula">
|
||||
单价 = 采购价 ÷ 汇率 ×(1 + 加价率),与询盘报价同一套口径。
|
||||
{rates && header.exchange_rate
|
||||
? ` 当前按 1 ${header.currency} = ${formatRate(parseAmount(header.exchange_rate) || 0)} CNY 换算。`
|
||||
: ' 汇率取不到时可以手动填。'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---------- ③ 报价明细 ---------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title">
|
||||
报价明细
|
||||
<span className="panel-title-hint">货号来自小推车,数量可在此微调(不会改动小推车)</span>
|
||||
</div>
|
||||
|
||||
{lines.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><CartIcon size={40} /></div>
|
||||
<div className="empty-state-title">小推车是空的</div>
|
||||
<div className="empty-state-hint">先到「产品管理」里勾选货号加入小推车,再回来出报价单。</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>货号</th>
|
||||
<th>产品 / 规格</th>
|
||||
<th className="num">采购价</th>
|
||||
<th className="num">数量</th>
|
||||
<th className="num">单价</th>
|
||||
<th className="num">小计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map(line => (
|
||||
<tr key={line.key} className={line.qty ? '' : 'row-pending'}>
|
||||
<td className="mono bold">
|
||||
<span className="sku-cell">
|
||||
<SkuThumb sku={line.sku} filename={line.image} size={30} />
|
||||
{line.sku}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>{line.product_name}</div>
|
||||
{line.spec && <div className="secondary">{line.spec}</div>}
|
||||
</td>
|
||||
<td className="num">
|
||||
{line.cost == null ? <span className="secondary">无采购价</span> : <>¥{money(line.cost)}</>}
|
||||
</td>
|
||||
<td className="num">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
className="quote-qty-input"
|
||||
value={qtyOverride[line.key] ?? line.qty ?? ''}
|
||||
placeholder="必填"
|
||||
onChange={event => setQtyOverride(current => ({ ...current, [line.key]: event.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
<td className="num">
|
||||
{line.unit == null ? <span className="secondary">—</span> : <b>{money(line.unit, 4)}</b>}
|
||||
</td>
|
||||
<td className="num">
|
||||
{line.total == null ? <span className="secondary">—</span> : `${money(line.total)} ${header.currency}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="quote-total-bar">
|
||||
{missingQty > 0 && <span className="quote-warn">还有 {missingQty} 行没填数量</span>}
|
||||
{missingPrice > 0 && <span className="quote-warn">{missingPrice} 行算不出单价(汇率或加价率未填)</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="quote-grand-total">
|
||||
报价合计 <b>{money(grandTotal)} {header.currency}</b>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---------- ④ 备注:对外 / 对内分开 ---------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title">备注</div>
|
||||
<div className="quote-notes-grid">
|
||||
<div className="form-group">
|
||||
<label>订单备注 <span className="label-badge">会显示在报价单</span></label>
|
||||
<textarea
|
||||
rows="3"
|
||||
className="quote-notes"
|
||||
value={header.order_notes}
|
||||
onChange={e => setField('order_notes', e.target.value)}
|
||||
placeholder="给客户看的补充说明,例如包装要求、交期、唛头。导出时以 Notes: 开头"
|
||||
/>
|
||||
{header.order_notes.trim() && (
|
||||
<span className="field-hint">报价单上呈现为:Notes: {header.order_notes.trim()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>系统备注 <span className="label-badge label-badge-internal">仅内部可见</span></label>
|
||||
<textarea
|
||||
rows="3"
|
||||
className="quote-notes"
|
||||
value={header.internal_notes}
|
||||
onChange={e => setField('internal_notes', e.target.value)}
|
||||
placeholder="内部备注:成本底线、客户砍价习惯、验厂情况等。不会出现在报价单上"
|
||||
/>
|
||||
<span className="field-hint">系统备注只留在系统里,报价单和导出文件都不带它。</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="quote-editor-footer">
|
||||
<button type="button" className="btn" onClick={() => navigate(-1)}>返回</button>
|
||||
{canEditQuote && (
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
|
||||
<QuoteIcon size={15} /> {saving ? '保存中…' : '保存报价单'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newCustomerOpen && (
|
||||
<NewCustomerModal
|
||||
initialCompany={newCustomerSeed}
|
||||
onClose={() => setNewCustomerOpen(false)}
|
||||
onCreated={async id => {
|
||||
setNewCustomerOpen(false)
|
||||
const { data } = await axios.get(`${API}/customers`)
|
||||
setCustomers(Array.isArray(data) ? data : [])
|
||||
await loadCustomer(id)
|
||||
setNotice('客户已创建,抬头信息已带出')
|
||||
}}
|
||||
canEditCustomer={canEditCustomer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuoteEditor
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
/**
|
||||
* 客户报价单管理
|
||||
* 数据来源:happy 后端 /api/quotes(P1 后端合并阶段开放)
|
||||
* 未接入前保持可用的空态,不做假数据。
|
||||
* 数据来源:happy 后端 /api/quotes
|
||||
*/
|
||||
function QuoteManagement() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'quote.edit')
|
||||
const [quotes, setQuotes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
@@ -32,7 +37,7 @@ function QuoteManagement() {
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">客户报价单管理</h1>
|
||||
<button className="btn btn-primary">+ 新建报价单</button>
|
||||
{canEdit && <button className="btn btn-primary" onClick={() => navigate('/quotes/new')}>+ 新建报价单</button>}
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
@@ -63,12 +68,12 @@ function QuoteManagement() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
<div className="empty-state-title">暂无报价单</div>
|
||||
<div className="empty-state-hint">报价单接口待接入(P1 后端合并阶段开放)</div>
|
||||
<div className="empty-state-hint">在「产品管理」里把货号加入小推车,点「汇总报价」就能生成第一张。</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -82,7 +87,7 @@ function QuoteManagement() {
|
||||
<td><span className={`status-badge status-${q.status}`}>{statusText(q.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">转 PI</button>
|
||||
{canEdit && <button className="btn-link">转 PI</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
import { GridIcon, ListIcon } from '../components/Icons'
|
||||
import { SkeletonCards, SkeletonPanel } from '../components/Skeleton'
|
||||
import { SearchField } from '../components/SearchSelect'
|
||||
import { SkuThumb } from '../components/SkuThumb'
|
||||
|
||||
/**
|
||||
* 供应商管理(原生 React 版)
|
||||
*
|
||||
* 由 SKU Manager 的「供应商管理」视图迁移而来,功能对齐:
|
||||
* 卡片列表 · 未分类入口 · 新建/编辑 · 删除 · 详情(联系方式 / 供货清单 / 合同记录)。
|
||||
* 数据经 /api/sku/* 同源代理访问,禁止硬编码 SKU 的独立端口。
|
||||
* 数据走 /api/sku/* 同源接口(SKU 模块已并入后端),禁止硬编码独立端口。
|
||||
*/
|
||||
|
||||
const API = '/api/sku'
|
||||
@@ -106,6 +110,8 @@ function Kv({ label, value, mono }) {
|
||||
|
||||
function SupplierManagement() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const canEdit = hasPermission(user, 'supplier.edit')
|
||||
|
||||
const [mode, setMode] = useState('list') // list | detail | form
|
||||
const [suppliers, setSuppliers] = useState([])
|
||||
@@ -125,6 +131,8 @@ function SupplierManagement() {
|
||||
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
// 详情页「供货清单」的即时过滤(一家供应商可能供几十上百个货号)
|
||||
const [productQuery, setProductQuery] = useState('')
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
@@ -165,6 +173,7 @@ function SupplierManagement() {
|
||||
setDetailLoading(true)
|
||||
setDetail(null)
|
||||
setNotice('')
|
||||
setProductQuery('')
|
||||
try {
|
||||
const { data } = await axios.get(`${API}/suppliers/${id}`)
|
||||
setDetail(data)
|
||||
@@ -246,7 +255,7 @@ function SupplierManagement() {
|
||||
<h1 className="page-title">供应商管理</h1>
|
||||
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>
|
||||
{canEdit && <button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>}
|
||||
</div>
|
||||
|
||||
{notice && <div className="notice-inline">{notice}</div>}
|
||||
@@ -285,7 +294,7 @@ function SupplierManagement() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">加载中…</div>
|
||||
<SkeletonCards count={8} />
|
||||
) : suppliers.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏭</div>
|
||||
@@ -385,6 +394,11 @@ function SupplierManagement() {
|
||||
const s = detail?.supplier
|
||||
const products = detail?.products || []
|
||||
const contracts = detail?.contracts || []
|
||||
const pk = productQuery.trim().toLowerCase()
|
||||
const visibleProducts = pk
|
||||
? products.filter((p) => [p.display_sku, p.sku, p.group_name, p.name, p.spec]
|
||||
.filter(Boolean).join(' ').toLowerCase().includes(pk))
|
||||
: products
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
@@ -401,14 +415,16 @@ function SupplierManagement() {
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn" onClick={() => { setDetail(null); setMode('list') }}>← 供应商列表</button>
|
||||
{s && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
|
||||
{s && canEdit && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <div className="notice-inline">{notice}</div>}
|
||||
|
||||
{detailLoading || !s ? (
|
||||
<div className="loading">加载中…</div>
|
||||
<div className="card">
|
||||
<SkeletonPanel lines={4} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card">
|
||||
@@ -431,12 +447,26 @@ function SupplierManagement() {
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>供货清单</h2>
|
||||
<span className="secondary">{products.length} 条</span>
|
||||
<span className="secondary">
|
||||
{productQuery.trim() ? `${visibleProducts.length} / ${products.length} 条` : `${products.length} 条`}
|
||||
</span>
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
{products.length > 1 && (
|
||||
<div className="panel-search-row">
|
||||
<SearchField
|
||||
size="sm"
|
||||
value={productQuery}
|
||||
onChange={setProductQuery}
|
||||
placeholder="搜货号 / 所属产品 / 规格…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleProducts.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 12px' }}>
|
||||
<div className="empty-state-hint">
|
||||
还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。
|
||||
{products.length === 0
|
||||
? '还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。'
|
||||
: '没有匹配的货号,换个关键词试试。'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -453,12 +483,10 @@ function SupplierManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
{visibleProducts.map((p) => (
|
||||
<tr key={`${p.sku}-${p.group_id ?? ''}`}>
|
||||
<td>
|
||||
{p.thumb
|
||||
? <img className="thumb" src={`/product-images/${p.thumb}`} alt="" loading="lazy" />
|
||||
: <span className="thumb thumb-empty" />}
|
||||
<SkuThumb sku={p.sku} filename={p.thumb} size={34} />
|
||||
</td>
|
||||
<td className="mono">{p.display_sku || p.sku}</td>
|
||||
<td>{p.group_name || p.name || '—'}</td>
|
||||
@@ -578,10 +606,12 @@ function SupplierManagement() {
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{editingId && (
|
||||
{canEdit && (
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
)}
|
||||
{editingId && canEdit && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => removeSupplier(editingId, editingName)}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { ActivityIcon, DownloadIcon, RefreshIcon, SearchIcon, ClockIcon, ShieldIcon } from '../components/Icons'
|
||||
import { setPageTitle } from '../branding'
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
系统日志(审计轨迹)—— 侧栏「系统管理」下的独立页面。
|
||||
参考 GoodJob 的系统日志页:四格统计 + 等级/分类筛选 + 搜索 + 导出 + 列表 + 展开详情。
|
||||
数据源 GET /api/logs(后端按 action 前缀归分类、按 ok 归等级)。
|
||||
入口与路由都认 audit.view —— 按 Wayne 的口径,仅系统管理员可见。
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
const LEVELS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'info', label: '信息' },
|
||||
{ key: 'success', label: '成功' },
|
||||
{ key: 'warning', label: '警告' },
|
||||
{ key: 'error', label: '错误' }
|
||||
]
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function fmtTime(value) {
|
||||
if (!value) return ''
|
||||
try {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function SystemLogs() {
|
||||
const [level, setLevel] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [data, setData] = useState(null)
|
||||
const [openId, setOpenId] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const alive = useRef(true)
|
||||
|
||||
useEffect(() => { setPageTitle('系统日志') }, [])
|
||||
|
||||
/* 搜索框防抖,避免每敲一个字打一次接口 */
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { setSearch(keyword.trim()); setOffset(0) }, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [keyword])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data: payload } = await axios.get('/api/logs', {
|
||||
params: { level: level || undefined, category: category || undefined, q: search || undefined, limit: PAGE_SIZE, offset }
|
||||
})
|
||||
if (!alive.current) return
|
||||
setData(payload)
|
||||
} catch (e) {
|
||||
if (alive.current) setError(e?.response?.data?.error || e.message)
|
||||
} finally {
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
}, [level, category, search, offset])
|
||||
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const items = data?.items || []
|
||||
const stats = data?.stats || { total: 0, today: 0, warning: 0, error: 0 }
|
||||
const total = data?.total || 0
|
||||
|
||||
const rangeText = useMemo(() => {
|
||||
if (!total) return '暂无记录'
|
||||
const from = offset + 1
|
||||
const to = Math.min(offset + PAGE_SIZE, total)
|
||||
return `第 ${from}–${to} 条 / 共 ${total} 条`
|
||||
}, [offset, total])
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await axios.get('/api/logs/export', {
|
||||
params: { level: level || undefined, category: category || undefined, q: search || undefined },
|
||||
responseType: 'blob'
|
||||
})
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `system-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.error || '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container logs-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">系统日志</h1>
|
||||
<p className="page-subtitle">
|
||||
账号登录与核心业务操作的审计轨迹,用于追溯与排障。仅系统管理员可见。
|
||||
{total > 0 && ` 共 ${total} 条记录。`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
|
||||
<RefreshIcon size={14} /> {loading ? '刷新中…' : '刷新'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={exportCsv} disabled={exporting || !total}>
|
||||
<DownloadIcon size={14} /> {exporting ? '导出中…' : '导出 CSV'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="log-stats">
|
||||
<div className="log-stat">
|
||||
<b>{stats.total}</b>
|
||||
<span>记录</span>
|
||||
</div>
|
||||
<div className="log-stat">
|
||||
<b>{stats.today}</b>
|
||||
<span>今日</span>
|
||||
</div>
|
||||
<div className="log-stat is-warn">
|
||||
<b>{stats.warning}</b>
|
||||
<span>警告</span>
|
||||
</div>
|
||||
<div className="log-stat is-err">
|
||||
<b>{stats.error}</b>
|
||||
<span>错误</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="log-toolbar">
|
||||
<div className="log-chips" role="tablist" aria-label="按等级筛选">
|
||||
{LEVELS.map(item => (
|
||||
<button
|
||||
key={item.key || 'all'}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={level === item.key}
|
||||
className={`log-chip ${level === item.key ? 'is-active' : ''} ${item.key ? 'is-' + item.key : ''}`}
|
||||
onClick={() => { setLevel(item.key); setOffset(0) }}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="log-toolbar">
|
||||
<div className="log-chips" role="tablist" aria-label="按分类筛选">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === ''}
|
||||
className={`log-chip ${category === '' ? 'is-active' : ''}`}
|
||||
onClick={() => { setCategory(''); setOffset(0) }}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{(data?.categories || []).map(item => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === item.key}
|
||||
className={`log-chip ${category === item.key ? 'is-active' : ''}`}
|
||||
onClick={() => { setCategory(item.key); setOffset(0) }}
|
||||
>
|
||||
{item.label}<small>{item.count}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="log-actions">
|
||||
<label className="log-search">
|
||||
<SearchIcon size={15} />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
placeholder="搜索操作人 / 动作 / 说明 / IP"
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="log-error"><ShieldIcon size={14} /> {error}</p>}
|
||||
|
||||
{!loading && !items.length && (
|
||||
<div className="log-empty">
|
||||
<ActivityIcon size={22} />
|
||||
<strong>暂无日志记录</strong>
|
||||
<small>登录系统或改动用户、权限、客户、商机与模型接入时,操作会自动记录到这里。</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!items.length && (
|
||||
<ul className="log-list">
|
||||
{items.map(row => (
|
||||
<li key={row.id} className={`log-row is-${row.level}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="log-row-main"
|
||||
aria-expanded={openId === row.id}
|
||||
onClick={() => setOpenId(openId === row.id ? null : row.id)}
|
||||
>
|
||||
<span className={`log-dot is-${row.level}`} aria-hidden="true" />
|
||||
<span className="log-time"><ClockIcon size={12} />{fmtTime(row.at)}</span>
|
||||
<span className={`log-badge is-${row.level}`}>{row.ok ? '' : '失败 · '}{row.action_label}</span>
|
||||
<span className="log-detail">{row.detail || '—'}</span>
|
||||
<span className="log-actor">{row.username || '—'}</span>
|
||||
<span className="log-cat">{row.category}</span>
|
||||
</button>
|
||||
{openId === row.id && (
|
||||
<dl className="log-more">
|
||||
<div><dt>动作标识</dt><dd>{row.action}</dd></div>
|
||||
<div><dt>操作人</dt><dd>{row.username || '—'}</dd></div>
|
||||
<div><dt>来源 IP</dt><dd>{row.ip || '—'}</dd></div>
|
||||
<div><dt>结果</dt><dd>{row.ok ? '成功' : '失败'}</dd></div>
|
||||
<div className="full"><dt>说明</dt><dd>{row.detail || '—'}</dd></div>
|
||||
<div className="full"><dt>User-Agent</dt><dd>{row.user_agent || '—'}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{(total > PAGE_SIZE || offset > 0) && (
|
||||
<div className="log-pager">
|
||||
<span>{rangeText}</span>
|
||||
<div>
|
||||
<button type="button" className="btn btn-secondary" disabled={offset === 0 || loading} onClick={() => setOffset(Math.max(offset - PAGE_SIZE, 0))}>上一页</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={offset + PAGE_SIZE >= total || loading} onClick={() => setOffset(offset + PAGE_SIZE)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemLogs
|
||||
@@ -0,0 +1,423 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { UserIcon, PlusIcon, RefreshIcon, SaveIcon } from '../components/Icons'
|
||||
import { SkeletonTableRows } from '../components/Skeleton'
|
||||
import { useAuth } from '../auth'
|
||||
import { setPageTitle } from '../branding'
|
||||
|
||||
/**
|
||||
* 用户管理(user.manage)。
|
||||
*
|
||||
* 口径(与权限组管理同一套推导):
|
||||
* - 角色只是职位标签 + 授权键;用户填角色,角色被指派进某个权限组,即获得该组权限。
|
||||
* - 所以「改用户角色」就是换权限的唯一入口,页面上要把「该角色现在落在哪个组」亮出来,
|
||||
* 免得管理员改完角色还以为权限没生效。
|
||||
* - 停用/改密会踢掉该用户已有会话(后端做),管理员自己的会话保留。
|
||||
*/
|
||||
|
||||
const EMPTY_NEW = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
role: 'sales_rep',
|
||||
org_id: '',
|
||||
position: '',
|
||||
email: ''
|
||||
}
|
||||
|
||||
function fmtDate(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function UserManagement() {
|
||||
const { user: me, refresh } = useAuth()
|
||||
const [users, setUsers] = useState([])
|
||||
const [roles, setRoles] = useState([])
|
||||
const [orgs, setOrgs] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ ...EMPTY_NEW })
|
||||
const [createError, setCreateError] = useState('')
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [editForm, setEditForm] = useState(null)
|
||||
const [editError, setEditError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [usersRes, rolesRes, orgsRes] = await Promise.all([
|
||||
axios.get('/api/users'),
|
||||
axios.get('/api/roles'),
|
||||
axios.get('/api/organizations')
|
||||
])
|
||||
setUsers(usersRes.data.users || [])
|
||||
setRoles(rolesRes.data.roles || [])
|
||||
setOrgs(orgsRes.data.organizations || [])
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '加载用户列表失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle('用户管理')
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const roleLabel = useCallback(value => (
|
||||
roles.find(item => item.value === value)?.label || value || '—'
|
||||
), [roles])
|
||||
|
||||
const activeCount = useMemo(() => users.filter(item => item.is_active).length, [users])
|
||||
|
||||
/* ---------------- 新建 ---------------- */
|
||||
const submitCreate = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!createForm.username.trim() || !createForm.password) {
|
||||
setCreateError('账号和初始密码必填')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setCreateError('')
|
||||
try {
|
||||
const { data } = await axios.post('/api/users', {
|
||||
...createForm,
|
||||
org_id: createForm.org_id === '' ? null : Number(createForm.org_id)
|
||||
})
|
||||
setNotice({ type: 'ok', text: `用户「${data.user.username}」已创建,可交由对方登录后自行改密` })
|
||||
setShowCreate(false)
|
||||
setCreateForm({ ...EMPTY_NEW })
|
||||
await load()
|
||||
} catch (error) {
|
||||
setCreateError(error?.response?.data?.error || '创建失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 编辑 ---------------- */
|
||||
const openEdit = (row) => {
|
||||
setEditing(row)
|
||||
setEditError('')
|
||||
setEditForm({
|
||||
display_name: row.display_name || '',
|
||||
position: row.position || '',
|
||||
email: row.email || '',
|
||||
phone: row.phone || '',
|
||||
role: row.role,
|
||||
org_id: row.org_id ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
const submitEdit = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!editing) return
|
||||
setBusy(true)
|
||||
setEditError('')
|
||||
try {
|
||||
await axios.patch(`/api/users/${editing.id}`, {
|
||||
...editForm,
|
||||
org_id: editForm.org_id === '' ? null : Number(editForm.org_id)
|
||||
})
|
||||
setNotice({ type: 'ok', text: `「${editing.username}」的资料已保存` })
|
||||
setEditing(null)
|
||||
await load()
|
||||
if (editing.id === me.id) await refresh() /* 改到自己身上时刷新自身权限显示 */
|
||||
} catch (error) {
|
||||
setEditError(error?.response?.data?.error || '保存失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 重置密码 / 停用 ---------------- */
|
||||
const resetPassword = async (row) => {
|
||||
const password = window.prompt(`给「${row.username}」设置新密码(至少 6 位):`)
|
||||
if (password === null) return
|
||||
if (password.length < 6) {
|
||||
setNotice({ type: 'error', text: '密码至少 6 位,未做修改' })
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await axios.patch(`/api/users/${row.id}`, { password })
|
||||
setNotice({ type: 'ok', text: `「${row.username}」的密码已重置,对方旧登录态已失效` })
|
||||
await load()
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '重置失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleActive = async (row) => {
|
||||
const next = !row.is_active
|
||||
const ok = window.confirm(
|
||||
next
|
||||
? `启用「${row.username}」?启用后对方即可正常登录。`
|
||||
: `停用「${row.username}」?停用后对方立即掉线、无法登录,客户等数据保留。`
|
||||
)
|
||||
if (!ok) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await axios.patch(`/api/users/${row.id}`, { is_active: next })
|
||||
setNotice({ type: 'ok', text: `「${row.username}」已${next ? '启用' : '停用'}` })
|
||||
await load()
|
||||
} catch (error) {
|
||||
setNotice({ type: 'error', text: error?.response?.data?.error || '操作失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container user-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">用户管理</h1>
|
||||
<p className="page-subtitle">
|
||||
账号的增删改都在这里。角色决定权限(角色 → 权限组的推导见「权限组管理」),
|
||||
停用会立即把对方踢下线。共 {users.length} 个账号,{activeCount} 个启用中。
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={load} disabled={busy}>
|
||||
<RefreshIcon size={14} /> 刷新
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => { setShowCreate(true); setCreateError('') }} disabled={busy}>
|
||||
<PlusIcon size={14} /> 新建用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>账号 / 姓名</th>
|
||||
<th>角色</th>
|
||||
<th>权限组</th>
|
||||
<th>组织</th>
|
||||
<th>最近登录</th>
|
||||
<th>状态</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<SkeletonTableRows rows={4} widths={[1.8, 1, 1, 1, 1, 0.7, 1.6]} />
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan="7" className="empty-state">还没有用户</td></tr>
|
||||
) : users.map(row => {
|
||||
const isSelf = row.id === me.id
|
||||
return (
|
||||
<tr key={row.id} className={row.is_active ? '' : 'row-muted'}>
|
||||
<td>
|
||||
<strong>{row.username}</strong>
|
||||
{isSelf && <span className="owner-self" style={{ marginLeft: 6 }}>我</span>}
|
||||
<div className="muted-note">
|
||||
{row.display_name || '—'}
|
||||
{row.position ? ` · ${row.position}` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="owner-cell"><UserIcon size={11} /> {roleLabel(row.role)}</span>
|
||||
</td>
|
||||
<td>{row.permission_group_name || <span className="muted-note">该角色未指派组</span>}</td>
|
||||
<td>{row.org_name || <span className="muted-note">未指定</span>}</td>
|
||||
<td className="mono">{fmtDate(row.last_login_at)}</td>
|
||||
<td>
|
||||
<span className={`pill ${row.is_active ? 'pill-ok' : 'pill-mute'}`}>
|
||||
{row.is_active ? '启用' : '停用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
<button type="button" className="btn-link" onClick={() => openEdit(row)}>编辑</button>
|
||||
<button type="button" className="btn-link" disabled={busy} onClick={() => resetPassword(row)}>重置密码</button>
|
||||
{!isSelf && (
|
||||
<button type="button" className="btn-link" disabled={busy} onClick={() => toggleActive(row)}>
|
||||
{row.is_active ? '停用' : '启用'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ---------------- 新建用户 ---------------- */}
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => !busy && setShowCreate(false)}>
|
||||
<div className="modal-content" onClick={event => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>新建用户</h2>
|
||||
<button type="button" className="icon-btn" onClick={() => setShowCreate(false)} aria-label="关闭">×</button>
|
||||
</div>
|
||||
<form onSubmit={submitCreate}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>登录账号 *</label>
|
||||
<input
|
||||
type="text" value={createForm.username} autoFocus
|
||||
placeholder="字母/数字,如 zhangsan"
|
||||
onChange={event => setCreateForm({ ...createForm, username: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>姓名</label>
|
||||
<input
|
||||
type="text" value={createForm.display_name}
|
||||
placeholder="对外显示的名字"
|
||||
onChange={event => setCreateForm({ ...createForm, display_name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>初始密码 *</label>
|
||||
<input
|
||||
type="text" value={createForm.password}
|
||||
placeholder="至少 6 位,交给对方后自行修改"
|
||||
onChange={event => setCreateForm({ ...createForm, password: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>角色(决定权限)</label>
|
||||
<select
|
||||
value={createForm.role}
|
||||
onChange={event => setCreateForm({ ...createForm, role: event.target.value })}
|
||||
>
|
||||
{roles.map(item => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}{item.group_name ? `(${item.group_name})` : '(未指派组)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>所属组织</label>
|
||||
<select
|
||||
value={createForm.org_id}
|
||||
onChange={event => setCreateForm({ ...createForm, org_id: event.target.value })}
|
||||
>
|
||||
<option value="">未指定</option>
|
||||
{orgs.map(org => <option key={org.id} value={String(org.id)}>{org.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>职位(对外称谓)</label>
|
||||
<input
|
||||
type="text" value={createForm.position}
|
||||
placeholder="如:外贸业务员"
|
||||
onChange={event => setCreateForm({ ...createForm, position: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{createError && <p className="form-error">{createError}</p>}
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)} disabled={busy}>取消</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
<SaveIcon size={14} /> {busy ? '创建中…' : '创建用户'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------------- 编辑用户 ---------------- */}
|
||||
{editing && editForm && (
|
||||
<div className="modal-overlay" onClick={() => !busy && setEditing(null)}>
|
||||
<div className="modal-content" onClick={event => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>编辑「{editing.username}」</h2>
|
||||
<button type="button" className="icon-btn" onClick={() => setEditing(null)} aria-label="关闭">×</button>
|
||||
</div>
|
||||
<form onSubmit={submitEdit}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>姓名</label>
|
||||
<input
|
||||
type="text" value={editForm.display_name}
|
||||
onChange={event => setEditForm({ ...editForm, display_name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>职位</label>
|
||||
<input
|
||||
type="text" value={editForm.position}
|
||||
onChange={event => setEditForm({ ...editForm, position: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>角色(决定权限)</label>
|
||||
<select
|
||||
value={editForm.role}
|
||||
disabled={editing.id === me.id}
|
||||
title={editing.id === me.id ? '不能修改自己的角色' : ''}
|
||||
onChange={event => setEditForm({ ...editForm, role: event.target.value })}
|
||||
>
|
||||
{roles.map(item => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}{item.group_name ? `(${item.group_name})` : '(未指派组)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>所属组织</label>
|
||||
<select
|
||||
value={editForm.org_id === null ? '' : editForm.org_id}
|
||||
onChange={event => setEditForm({ ...editForm, org_id: event.target.value })}
|
||||
>
|
||||
<option value="">未指定</option>
|
||||
{orgs.map(org => <option key={org.id} value={String(org.id)}>{org.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>邮箱</label>
|
||||
<input
|
||||
type="text" value={editForm.email}
|
||||
onChange={event => setEditForm({ ...editForm, email: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>电话</label>
|
||||
<input
|
||||
type="text" value={editForm.phone}
|
||||
onChange={event => setEditForm({ ...editForm, phone: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{editError && <p className="form-error">{editError}</p>}
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditing(null)} disabled={busy}>取消</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
<SaveIcon size={14} /> {busy ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserManagement
|
||||
Reference in New Issue
Block a user