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:
Drsakura
2026-09-17 14:53:02 +08:00
parent 436519e0a6
commit 13d67c804f
77 changed files with 18407 additions and 955 deletions
+747
View File
@@ -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
+933
View File
@@ -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
+244 -77
View File
@@ -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>
+205
View File
@@ -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
+199 -10
View File
@@ -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>
-141
View File
@@ -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
+601
View File
@@ -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
+357
View File
@@ -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
+106
View File
@@ -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
+31 -3
View File
@@ -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>
))
+280
View File
@@ -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
+7 -3
View File
@@ -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>
))
+525 -118
View File
@@ -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>
)
+345
View File
@@ -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
+221 -87
View File
@@ -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>
))
+807
View File
@@ -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
+11 -6
View File
@@ -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>
))
+46 -16
View File
@@ -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)}
+257
View File
@@ -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
+423
View File
@@ -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