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
+17 -11
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { BotIcon, CloseIcon, MinusIcon, SendIcon, SparkIcon } from './Icons'
import { AIBotIcon, CloseIcon, MinusIcon, SendIcon, UserIcon } from './Icons'
const POS_KEY = 'happy.aiPos'
const WIN_WIDTH = 348
const WIN_WIDTH = 440
const starterMessages = [
{ role: 'assistant', text: '我是小皮,你的外贸业务助理。可以帮你查客户、产品、供应商、订单、商机和待办,也能代你发起系统内的操作。' },
@@ -135,22 +135,27 @@ function AIAssistant({ isOpen, onClose }) {
onDoubleClick={() => setMinimized(value => !value)}
>
<div className="ai-panel-title">
<span className="ai-orb"><BotIcon size={16} /></span>
<div><span className="section-label">[COPILOT]</span><h2 id="ai-assistant-title">小皮</h2></div>
<span className="ai-orb"><AIBotIcon size={16} /></span>
<div className="ai-panel-heading">
<span className="ai-panel-kicker">BUSINESS COPILOT</span>
<h2 id="ai-assistant-title">小皮</h2>
<span className="ai-panel-subtitle">你的外贸业务助理</span>
</div>
</div>
<div className="ai-window-actions">
{!minimized && <span className="ai-live-dot" title="已连接系统业务层" />}
{!minimized && <span className="ai-status-label"><span className="ai-live-dot" />在线</span>}
<button
type="button"
className="ai-icon-btn"
className="ai-icon-btn ai-minimize-btn"
onClick={() => setMinimized(value => !value)}
aria-label={minimized ? '展开小皮' : '最小化小皮'}
title={minimized ? '展开' : '最小化(也可以双击标题栏)'}
>
{minimized ? <SparkIcon size={15} /> : <MinusIcon size={16} />}
{minimized ? <AIBotIcon size={15} /> : <MinusIcon size={16} />}
</button>
<button type="button" className="ai-icon-btn" onClick={onClose} aria-label="关闭小皮" title="关闭">
<button type="button" className="ai-close-btn" onClick={onClose} aria-label="关闭小皮" title="关闭小皮">
<CloseIcon size={15} />
<span>关闭</span>
</button>
</div>
</div>
@@ -159,11 +164,11 @@ function AIAssistant({ isOpen, onClose }) {
<React.Fragment>
<div className="ai-panel-body">
<div className="ai-access-banner"><span className="ai-live-dot" /><div><strong>已连接系统业务层</strong><small>可读取模块数据,敏感写操作会先请求确认</small></div></div>
<div className="ai-capability-card"><div className="ai-capability-heading"><SparkIcon size={15} />我能帮你</div>{capabilities.map(capability => <div className="ai-capability-row" key={capability}><span>✓</span>{capability}</div>)}</div>
<div className="ai-capability-card"><div className="ai-capability-heading">我能帮你</div>{capabilities.map(capability => <div className="ai-capability-row" key={capability}><span>✓</span>{capability}</div>)}</div>
{context && <div className="ai-context-strip"><span>当前数据快照</span><b>{context.stats?.customers || 0} 客户</b><b>{context.stats?.products || 0} 产品</b><b>{context.stats?.orders || 0} 订单</b></div>}
<div className="ai-message-list">
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <BotIcon size={15} /> : 'W'}</div><div className="ai-message-content"><p>{message.text}</p>{message.meta && <small>{message.meta}</small>}</div></div>)}
{loading && <div className="ai-message assistant"><div className="ai-message-avatar"><BotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <AIBotIcon size={15} /> : <UserIcon size={15} />}</div><div className="ai-message-content"><p>{message.text}</p>{message.meta && <small>{message.meta}</small>}</div></div>)}
{loading && <div className="ai-message assistant"><div className="ai-message-avatar"><AIBotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
</div>
</div>
@@ -177,3 +182,4 @@ function AIAssistant({ isOpen, onClose }) {
}
export default AIAssistant
@@ -0,0 +1,721 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import {
SparkIcon, PlusIcon, RefreshIcon, CheckIcon, CloseIcon, SaveIcon, SendIcon
} from './Icons'
/**
* 智能配置 —— 多模型接入与用途分配。
*
* 交互主线(Wayne 2026-09-15 定的四步):
* 1. 选一个常用接口预设(自动填地址/兼容模式/推荐模型)
* 2. 填密钥 → 保存(保存后自动跑一次连通性测试)
* 3. 「获取模型」→ 勾选这个接入要用哪些模型
* 4. 到「按用途分配」把不同活派给不同模型(可以同时接多家)
*
* 所有配置都在后端,前端不做任何本地持久化 —— 换台电脑登录照样是这套配置。
*/
const VALUE_SEP = '::'
const encodeChoice = (providerId, model) => (providerId && model ? `${providerId}${VALUE_SEP}${model}` : '')
const decodeChoice = (value) => {
if (!value) return null
const index = value.indexOf(VALUE_SEP)
if (index < 0) return null
return { provider_id: value.slice(0, index), model: value.slice(index + VALUE_SEP.length) }
}
function fmtTime(value) {
if (!value) return ''
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(value)
}
}
/**
* Base URL 的即时体检。
*
* 两种兼容模式的地址写法**不一样**,这里拦最常见的两类填错:
* ① 把完整接口路径也填了进来(/chat/completions、/messages)—— 系统还会再拼一次;
* ② Anthropic 协议漏了 /v1 —— 各家 SDK 文档里的 base_url 是写到 /anthropic 为止的
* (SDK 自己补 /v1),本系统不补,直接照抄必然 404。
*/
function baseUrlAdvice(mode, value) {
const url = String(value || '').trim()
if (!url) return null
const tail = url.replace(/\/+$/, '')
if (/\/(chat\/completions|completions)$/i.test(tail)) {
return { level: 'error', text: '不要带 /chat/completions —— 系统会自动拼上它,填到服务根路径就行。' }
}
if (/\/messages$/i.test(tail)) {
return { level: 'error', text: '不要带 /messages —— 系统会自动拼上它,填到服务根路径就行。' }
}
if (mode === 'anthropic' && !/\/v1$/i.test(tail)) {
return {
level: 'warn',
text: 'Anthropic 协议通常要写到 /v1 结尾(如 …/anthropic/v1)—— 系统是在这一层的后面拼 /messages,'
+ '漏掉 /v1 多半会 404。'
}
}
return null
}
function AIConfigSettings() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [config, setConfig] = useState({
presets: [], modes: [], purposes: [], providers: [], assignments: {}, overview: {}
})
const [editor, setEditor] = useState(null)
const [busy, setBusy] = useState('')
const [modelPanel, setModelPanel] = useState(null)
const [draft, setDraft] = useState({})
const [probe, setProbe] = useState({ prompt: '', reply: '', error: '', busy: false })
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await axios.get('/api/ai/config')
setConfig(data)
const next = {}
for (const [purpose, value] of Object.entries(data.assignments || {})) {
next[purpose] = encodeChoice(value.provider_id, value.model)
}
setDraft(next)
setError('')
} catch (err) {
setError(err?.response?.data?.error || '读取智能配置失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const flash = (text) => {
setNotice(text)
window.setTimeout(() => setNotice(''), 3500)
}
/* 所有启用中的接入 × 它们的模型,供用途分配下拉用 */
const choices = useMemo(() => (config.providers || [])
.filter(provider => provider.enabled)
.map(provider => ({
provider,
models: provider.models || [],
label: `${provider.name}${provider.has_key ? '' : '(未填密钥)'}`
})), [config.providers])
const assignedCount = useMemo(
() => Object.values(draft).filter(Boolean).length,
[draft]
)
/* ---------------------------------------------------------------- 接入 */
const openCreate = (presetKey = '') => {
const preset = (config.presets || []).find(item => item.key === presetKey) || null
setEditor({
mode: 'create',
form: {
preset: preset?.key || 'openai',
name: preset?.label || '',
base_url: preset?.base_url || '',
mode: preset?.mode || 'openai',
api_key: '',
note: '',
enabled: true
},
modelsText: (preset?.models || []).join('\n')
})
}
const openEdit = (provider) => {
setEditor({
mode: 'edit',
id: provider.id,
form: {
preset: provider.preset,
name: provider.name,
base_url: provider.base_url,
mode: provider.mode,
api_key: '',
note: provider.note || '',
enabled: provider.enabled
},
keyMasked: provider.key_masked,
modelsText: (provider.models || []).join('\n')
})
}
const changePreset = (presetKey) => {
const preset = (config.presets || []).find(item => item.key === presetKey)
if (!preset) return
setEditor(current => ({
...current,
form: {
...current.form,
preset: preset.key,
name: preset.label,
base_url: preset.base_url,
mode: preset.mode
},
modelsText: (preset.models || []).join('\n')
}))
}
const saveProvider = async (event) => {
event.preventDefault()
if (!editor) return
setBusy('save-provider')
try {
const payload = {
...editor.form,
models: editor.modelsText.split('\n').map(line => line.trim()).filter(Boolean)
}
if (editor.mode === 'edit' && !payload.api_key) delete payload.api_key
const { data } = editor.mode === 'edit'
? await axios.patch(`/api/ai/providers/${editor.id}`, payload)
: await axios.post('/api/ai/providers', payload)
setEditor(null)
await load()
flash(`「${data.provider.name}」已保存`)
/* 保存完顺手测一次:用户填完 key 最想知道的就是「通不通」 */
await runTest(data.provider.id, { silent: true })
} catch (err) {
setError(err?.response?.data?.error || '保存模型接入失败')
} finally {
setBusy('')
}
}
const runTest = async (id, { silent } = {}) => {
setBusy(`test:${id}`)
try {
const { data } = await axios.post(`/api/ai/providers/${id}/test`)
if (!silent) {
flash(data.result.ok
? `连通正常(${data.result.latency_ms}ms):${data.result.detail}`
: `连通失败:${data.result.detail}`)
}
setConfig(current => ({
...current,
providers: current.providers.map(item => (item.id === id ? data.provider : item))
}))
return data.result
} catch (err) {
const text = err?.response?.data?.error || '测试失败'
if (!silent) setError(text)
return { ok: false, detail: text }
} finally {
setBusy('')
}
}
const fetchModels = async (provider) => {
setBusy(`models:${provider.id}`)
try {
const { data } = await axios.post(`/api/ai/providers/${provider.id}/models`)
/* 勾选默认值:优先沿用之前存过的模型;一个都没对上(换了服务商/换了模型名)就全选,
否则用户会看到一个「获取成功但一个都没勾」的空面板,还得手工点一遍。 */
const previous = (provider.models || []).filter(model => data.models.includes(model))
setModelPanel({
providerId: provider.id,
providerName: provider.name,
detail: data.detail,
source: data.source,
selected: previous.length ? previous : data.models,
models: data.models
})
} catch (err) {
setError(err?.response?.data?.error || '获取模型清单失败')
} finally {
setBusy('')
}
}
const saveModels = async () => {
if (!modelPanel) return
setBusy('save-models')
try {
await axios.patch(`/api/ai/providers/${modelPanel.providerId}`, { models: modelPanel.selected })
setModelPanel(null)
await load()
flash('可用模型已更新')
} catch (err) {
setError(err?.response?.data?.error || '保存模型清单失败')
} finally {
setBusy('')
}
}
const toggleProvider = async (provider) => {
setBusy(`toggle:${provider.id}`)
try {
await axios.patch(`/api/ai/providers/${provider.id}`, { enabled: !provider.enabled })
await load()
flash(`「${provider.name}」已${provider.enabled ? '停用' : '启用'}`)
} catch (err) {
setError(err?.response?.data?.error || '操作失败')
} finally {
setBusy('')
}
}
const removeProvider = async (provider) => {
if (!window.confirm(`移除模型接入「${provider.name}」?\n\n指向它的用途分配会一起清掉。`)) return
setBusy(`remove:${provider.id}`)
try {
await axios.delete(`/api/ai/providers/${provider.id}`)
await load()
flash(`「${provider.name}」已移除`)
} catch (err) {
setError(err?.response?.data?.error || '移除失败')
} finally {
setBusy('')
}
}
/* ---------------------------------------------------------------- 分配 */
const saveAssignments = async () => {
setBusy('assignments')
try {
const assignments = {}
for (const [purpose, value] of Object.entries(draft)) {
const choice = decodeChoice(value)
if (choice) assignments[purpose] = choice
}
const { data } = await axios.put('/api/ai/assignments', { assignments })
setConfig(current => ({ ...current, assignments: data.assignments, overview: data.overview }))
flash('用途分配已保存')
} catch (err) {
setError(err?.response?.data?.error || '保存分配失败')
} finally {
setBusy('')
}
}
const runProbe = async () => {
const prompt = probe.prompt.trim()
if (!prompt) return
setProbe(current => ({ ...current, busy: true, reply: '', error: '' }))
try {
const { data } = await axios.post('/api/ai/chat', { prompt, purpose: 'assistant_chat' })
setProbe(current => ({ ...current, busy: false, reply: data.text || '(模型返回空内容)' }))
} catch (err) {
setProbe(current => ({
...current,
busy: false,
error: err?.response?.data?.error || '试跑失败'
}))
}
}
/* ---------------------------------------------------------------- 渲染 */
if (loading) {
return (
<div className="settings-section">
<h3>智能配置</h3>
<p className="settings-description">正在读取模型接入配置…</p>
</div>
)
}
const { overview = {} } = config
/* 编辑弹窗里跟着「接口兼容模式」走派生值:示例、后缀说明、填错提示 */
const activeMode = editor
? (config.modes || []).find(item => item.value === editor.form.mode) || null
: null
const baseAdvice = editor ? baseUrlAdvice(editor.form.mode, editor.form.base_url) : null
return (
<div className="settings-section">
<h3>智能配置</h3>
<p className="settings-description">
接入一家或多家大模型服务,再把不同用途派给不同模型 —— 合同解析用长文本强的,
图片识别用多模态的,日常对话用便宜的。全部配置存在本机数据库,换电脑登录即生效。
</p>
<div className="ai-overview">
<div><strong>{overview.provider_count || 0}</strong><small>已接入</small></div>
<div><strong>{overview.healthy_count || 0}</strong><small>测试通过</small></div>
<div><strong>{overview.assigned_purposes?.length || 0}/{config.purposes.length}</strong><small>用途已分配</small></div>
<div><strong>{overview.last_test_at ? fmtTime(overview.last_test_at).slice(5, 16) : '—'}</strong><small>最近测试</small></div>
</div>
{notice && <p className="ai-banner is-ok"><CheckIcon size={14} /> {notice}</p>}
{error && <p className="ai-banner is-error"><CloseIcon size={14} /> {error}</p>}
{/* ---------------- 模型接入 ---------------- */}
<div className="settings-group">
<div className="ai-group-head">
<h4>模型接入</h4>
<div className="ai-group-actions">
<button className="btn btn-secondary btn-sm" onClick={load} disabled={Boolean(busy)}>
<RefreshIcon size={14} /> 刷新
</button>
<button className="btn btn-primary btn-sm" onClick={() => openCreate('deepseek')}>
<PlusIcon size={14} /> 新增接入
</button>
</div>
</div>
{config.providers.length === 0
? <p className="help-text">
还没有接入任何模型服务。可以从下面的常用预设开始(DeepSeek / 通义 / Kimi / Claude / 本地 Ollama 等),
也可以完全自定义。不接也能用系统 —— 只是「小皮」会退回到本地规则直答。
</p>
: (
<ul className="ai-provider-list">
{config.providers.map(provider => {
const test = provider.last_test
return (
<li key={provider.id} className={`ai-provider${provider.enabled ? '' : ' is-off'}`}>
<div className="ai-provider-main">
<div className="ai-provider-title">
<strong>{provider.name}</strong>
<span className="ai-tag">{provider.preset_label}</span>
<span className="ai-tag is-mode">{provider.mode === 'anthropic' ? 'Anthropic 协议' : 'OpenAI 兼容'}</span>
{!provider.enabled && <span className="ai-tag is-off">已停用</span>}
</div>
<div className="ai-provider-meta">
<code>{provider.base_url}</code>
<span>密钥 {provider.has_key ? provider.key_masked : '(未填写,本地服务可留空)'}</span>
<span>{provider.models.length} 个可用模型</span>
</div>
{test && (
<div className={`ai-provider-test${test.ok ? ' is-ok' : ' is-error'}`}>
{test.ok ? <CheckIcon size={13} /> : <CloseIcon size={13} />}
<span>{test.detail}</span>
<small>{fmtTime(test.at)} · {test.latency_ms}ms</small>
</div>
)}
</div>
<div className="ai-provider-actions">
<button
className="btn btn-secondary btn-sm"
onClick={() => runTest(provider.id)}
disabled={Boolean(busy)}
>
{busy === `test:${provider.id}` ? '测试中…' : '连通性测试'}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => fetchModels(provider)}
disabled={Boolean(busy)}
>
{busy === `models:${provider.id}` ? '获取中…' : '获取模型'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => openEdit(provider)} disabled={Boolean(busy)}>
编辑
</button>
<button className="btn-link" onClick={() => toggleProvider(provider)} disabled={Boolean(busy)}>
{provider.enabled ? '停用' : '启用'}
</button>
<button className="btn-link is-danger" onClick={() => removeProvider(provider)} disabled={Boolean(busy)}>
移除
</button>
</div>
{modelPanel?.providerId === provider.id && (
<div className="ai-model-panel">
<div className="ai-model-panel-head">
<span>可用模型({modelPanel.models.length})</span>
<small>{modelPanel.detail}{modelPanel.source === 'preset' ? ' · 预设推荐' : ''}</small>
</div>
<div className="ai-model-grid">
{modelPanel.models.map(model => {
const checked = modelPanel.selected.includes(model)
return (
<label key={model} className={`ai-model-item${checked ? ' is-on' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => setModelPanel(current => ({
...current,
selected: checked
? current.selected.filter(item => item !== model)
: [...current.selected, model]
}))}
/>
<span>{model}</span>
</label>
)
})}
</div>
<div className="ai-model-panel-foot">
<button className="btn btn-secondary btn-sm" onClick={() => setModelPanel(null)}>取消</button>
<button className="btn btn-primary btn-sm" onClick={saveModels} disabled={busy === 'save-models'}>
<SaveIcon size={14} /> 保存为可用模型({modelPanel.selected.length})
</button>
</div>
</div>
)}
</li>
)
})}
</ul>
)}
</div>
{/* ---------------- 用途分配 ---------------- */}
<div className="settings-group">
<div className="ai-group-head">
<h4>按用途分配模型</h4>
<div className="ai-group-actions">
<span className="help-text">已分配 {assignedCount}/{config.purposes.length}</span>
<button
className="btn btn-primary btn-sm"
onClick={saveAssignments}
disabled={Boolean(busy) || config.providers.length === 0}
>
<SaveIcon size={14} /> {busy === 'assignments' ? '保存中…' : '保存分配'}
</button>
</div>
</div>
{config.providers.length === 0
? <p className="help-text">先在上面接入至少一家模型服务,这里就能选了。</p>
: (
<table className="data-table ai-assign-table">
<thead>
<tr><th>用途</th><th>说明</th><th>使用的模型</th><th /></tr>
</thead>
<tbody>
{config.purposes.map(purpose => (
<tr key={purpose.key}>
<td>
<strong>{purpose.label}</strong>
{purpose.vision && <span className="ai-tag is-vision">需多模态</span>}
</td>
<td className="ai-assign-hint">{purpose.hint}</td>
<td>
<select
className="form-select"
value={draft[purpose.key] || ''}
onChange={event => setDraft(current => ({ ...current, [purpose.key]: event.target.value }))}
>
<option value="">—— 未分配(走本地规则)——</option>
{choices.map(group => (
<optgroup key={group.provider.id} label={group.label}>
{group.models.length === 0 && (
<option value="" disabled>(该接入还没有可用模型,先点「获取模型」)</option>
)}
{group.models.map(model => (
<option key={model} value={encodeChoice(group.provider.id, model)}>
{model}
</option>
))}
</optgroup>
))}
</select>
</td>
<td>
{draft[purpose.key] && (
<button
className="btn-link"
onClick={() => setDraft(current => ({ ...current, [purpose.key]: '' }))}
>
清空
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ---------------- 试跑 ---------------- */}
<div className="settings-group">
<h4>验证一下</h4>
<p className="help-text">
用「智能助手对话」分配到的模型发一句话,确认配置真的通了。未分配时会提示先去做分配。
</p>
<div className="ai-probe">
<input
type="text"
className="form-input"
placeholder="例如:用一句话介绍我这边的客户情况"
value={probe.prompt}
onChange={event => setProbe(current => ({ ...current, prompt: event.target.value }))}
onKeyDown={event => { if (event.key === 'Enter') runProbe() }}
/>
<button className="btn btn-primary" onClick={runProbe} disabled={probe.busy || !probe.prompt.trim()}>
<SendIcon size={14} /> {probe.busy ? '调用中…' : '试跑'}
</button>
</div>
{probe.reply && <pre className="ai-probe-reply">{probe.reply}</pre>}
{probe.error && <p className="ai-banner is-error"><CloseIcon size={14} /> {probe.error}</p>}
</div>
<p className="appearance-note">
<SparkIcon size={14} /> 密钥以明文保存在本机数据库文件里,请勿把 data/trade.db 外发;
对外接口只会返回打码后的密钥。
</p>
{/* ---------------- 新增 / 编辑弹窗 ---------------- */}
{editor && (
<div className="modal-overlay" onClick={() => !busy && setEditor(null)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>{editor.mode === 'edit' ? `编辑接入「${editor.form.name}」` : '新增模型接入'}</h2>
<button className="close-btn" onClick={() => setEditor(null)} aria-label="关闭">
<CloseIcon size={18} />
</button>
</div>
<form onSubmit={saveProvider}>
<div className="form-group">
<label>常用接口预设</label>
<select
value={editor.form.preset}
onChange={event => changePreset(event.target.value)}
>
{config.presets.map(preset => (
<option key={preset.key} value={preset.key}>{preset.label}</option>
))}
</select>
{(() => {
const preset = config.presets.find(item => item.key === editor.form.preset)
if (!preset?.note) return null
return (
<p className="form-hint">
{preset.note}
{preset.docs && (
<>
{' · '}
<a href={preset.docs} target="_blank" rel="noreferrer">去申请密钥</a>
</>
)}
</p>
)
})()}
</div>
<div className="form-row">
<div className="form-group">
<label>接入名称</label>
<input
type="text"
value={editor.form.name}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, name: event.target.value } }))}
placeholder="例如 DeepSeek 生产"
/>
</div>
<div className="form-group">
<label>接口兼容模式</label>
<select
value={editor.form.mode}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, mode: event.target.value } }))}
>
{config.modes.map(mode => (
<option key={mode.value} value={mode.value}>{mode.label} —— {mode.hint}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label>
接口地址(Base URL)
{activeMode?.base_suffix && <span className="ai-url-suffix">系统会自动拼 {activeMode.base_suffix}</span>}
</label>
<input
type="text"
value={editor.form.base_url}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, base_url: event.target.value } }))}
placeholder={activeMode?.base_placeholder || 'https://api.example.com/v1'}
/>
{activeMode?.base_hint && <p className="form-hint">{activeMode.base_hint}</p>}
{baseAdvice && (
<p className={`form-hint is-${baseAdvice.level}`}>{baseAdvice.text}</p>
)}
{activeMode?.base_examples?.length > 0 && (
<div className="ai-url-examples">
<span className="ai-url-examples-label">{activeMode.label}常见地址(点一下填入):</span>
{activeMode.base_examples.map(item => (
<button
key={item.url}
type="button"
className="ai-url-chip"
title={`填入 ${item.url}`}
onClick={() => setEditor(current => ({
...current,
form: { ...current.form, base_url: item.url }
}))}
>
<em>{item.label}</em>
<code>{item.url}</code>
</button>
))}
</div>
)}
</div>
<div className="form-group">
<label>API Key</label>
<input
type="password"
value={editor.form.api_key}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, api_key: event.target.value } }))}
placeholder={editor.mode === 'edit' && editor.keyMasked
? `已保存:${editor.keyMasked}(留空表示不修改)`
: 'sk-...'}
/>
</div>
<div className="form-group">
<label>可用模型(一行一个;也可以保存后点「获取模型」自动填充)</label>
<textarea
className="ai-models-textarea"
rows={5}
value={editor.modelsText}
onChange={event => setEditor(current => ({ ...current, modelsText: event.target.value }))}
placeholder={'deepseek-chat\ndeepseek-reasoner'}
/>
</div>
<div className="form-group">
<label>备注</label>
<input
type="text"
value={editor.form.note}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, note: event.target.value } }))}
placeholder="例如:给合同解析用"
/>
</div>
<label className="setting-toggle">
<span><strong>启用该接入</strong><small>停用后不会被任何用途调用</small></span>
<input
type="checkbox"
checked={editor.form.enabled}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, enabled: event.target.checked } }))}
/>
<i aria-hidden="true" />
</label>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setEditor(null)} disabled={Boolean(busy)}>
取消
</button>
<button type="submit" className="btn btn-primary" disabled={Boolean(busy)}>
{busy === 'save-provider' ? '保存中…' : '保存并测试连通性'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default AIConfigSettings
+228
View File
@@ -0,0 +1,228 @@
import React from 'react'
/**
* 币种小国旗(纯 SVG 绘制,不使用 Emoji)
*
* 为什么不用 Emoji:🇺🇸 这类「区域指示符」在 Windows 的 Segoe UI Emoji 上
* 根本不渲染成旗子,只会显示成 US 两个字母方块;而且颜色不可控、尺寸不齐。
* 所以这里手绘 13 面旗,统一 viewBox 0 0 24 16,靠 CSS 控制大小与描边。
*
* 关于中国香港 / 中国台湾:香港用香港特别行政区区旗;台湾地区没有经
* 国家认可的区旗,这里用红底白字「台」作为**区划标识**,不是国旗。
*/
/** 五角星:单位坐标,几何中心在原点,外接圆半径 1(内接半径取 0.382) */
const STAR =
'0,-1 0.2246,-0.309 0.9511,-0.309 0.3633,0.118 0.5878,0.809 0,0.382 ' +
'-0.5878,0.809 -0.3633,0.118 -0.9511,-0.309 -0.2246,-0.309'
/** 欧元旗 12 颗星的位置(以 12,8 为中心、半径 5 的圆上均分) */
const EU_STARS = [
[17, 8], [16.33, 10.5], [14.5, 12.33], [12, 13], [9.5, 12.33], [7.67, 10.5],
[7, 8], [7.67, 5.5], [9.5, 3.67], [12, 3], [14.5, 3.67], [16.33, 5.5]
]
/** 简明国旗集:只画能一眼认出来的特征,不追求像素级还原 */
const FLAGS = {
/* 美国:13 道红白条纹 + 蓝色星区 */
USD: (
<>
<rect width="24" height="16" fill="#ffffff" />
<g fill="#b22234">
<rect y="0" width="24" height="1.23" />
<rect y="2.46" width="24" height="1.23" />
<rect y="4.92" width="24" height="1.23" />
<rect y="7.38" width="24" height="1.23" />
<rect y="9.85" width="24" height="1.23" />
<rect y="12.31" width="24" height="1.23" />
<rect y="14.77" width="24" height="1.23" />
</g>
<rect width="9.8" height="8.62" fill="#3c3b6e" />
<g fill="#ffffff">
{[0, 1, 2, 3, 4].map(i => <circle key={`a${i}`} cx={1.4 + i * 1.75} cy="1.4" r="0.48" />)}
{[0, 1, 2, 3].map(i => <circle key={`b${i}`} cx={2.28 + i * 1.75} cy="2.95" r="0.48" />)}
{[0, 1, 2, 3, 4].map(i => <circle key={`c${i}`} cx={1.4 + i * 1.75} cy="4.5" r="0.48" />)}
{[0, 1, 2, 3].map(i => <circle key={`d${i}`} cx={2.28 + i * 1.75} cy="6.05" r="0.48" />)}
{[0, 1, 2].map(i => <circle key={`e${i}`} cx={2.28 + i * 1.75} cy="7.55" r="0.48" />)}
</g>
</>
),
/* 中国:五星红旗 */
CNY: (
<>
<rect width="24" height="16" fill="#de2910" />
<g fill="#ffde00">
<polygon points={STAR} transform="translate(4.4,5.4) scale(2.6)" />
<polygon points={STAR} transform="translate(9,1.9) scale(0.85)" />
<polygon points={STAR} transform="translate(10.9,3.9) scale(0.85)" />
<polygon points={STAR} transform="translate(10.9,6.5) scale(0.85)" />
<polygon points={STAR} transform="translate(9,8.5) scale(0.85)" />
</g>
</>
),
/* 欧元区:蓝底 + 12 颗黄星围成一圈 */
EUR: (
<>
<rect width="24" height="16" fill="#003399" />
<g fill="#ffcc00">
{EU_STARS.map(([x, y], i) => (
<polygon key={i} points={STAR} transform={`translate(${x},${y}) scale(0.92)`} />
))}
</g>
</>
),
/* 中国香港:香港特别行政区区旗(洋紫荆) */
HKD: (
<>
<rect width="24" height="16" fill="#de2910" />
<g fill="#ffffff">
{[0, 72, 144, 216, 288].map(deg => (
<ellipse key={deg} cx="12" cy="5.15" rx="1.15" ry="2.85" transform={`rotate(${deg} 12 8)`} />
))}
</g>
<circle cx="12" cy="8" r="0.95" fill="#de2910" />
</>
),
/* 日本:白底红日 */
JPY: (
<>
<rect width="24" height="16" fill="#ffffff" />
<circle cx="12" cy="8" r="4.3" fill="#bc002d" />
</>
),
/* 俄罗斯:白蓝红三横条 */
RUB: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect y="5.33" width="24" height="5.34" fill="#0039a6" />
<rect y="10.67" width="24" height="5.33" fill="#d52b1e" />
</>
),
/* 马来西亚:红白条纹 + 蓝底黄月黄星 */
MYR: (
<>
<rect width="24" height="16" fill="#ffffff" />
<g fill="#cc0001">
<rect y="0" width="24" height="2.29" />
<rect y="4.57" width="24" height="2.29" />
<rect y="9.14" width="24" height="2.29" />
<rect y="13.71" width="24" height="2.29" />
</g>
<rect width="12" height="9.15" fill="#010066" />
<circle cx="4.7" cy="4.57" r="2.75" fill="#ffcc00" />
<circle cx="5.95" cy="4.57" r="2.35" fill="#010066" />
<polygon points={STAR} transform="translate(8.5,4.57) scale(1.02)" fill="#ffcc00" />
</>
),
/* 新加坡:上红下白 + 白色月与五星 */
SGD: (
<>
<rect width="24" height="8" fill="#ed2939" />
<rect y="8" width="24" height="8" fill="#ffffff" />
<circle cx="5.1" cy="4" r="2.9" fill="#ffffff" />
<circle cx="6.5" cy="4" r="2.5" fill="#ed2939" />
<g fill="#ffffff">
{[[8.6, 6.05], [6.62, 4.65], [7.37, 2.33], [9.83, 2.33], [10.58, 4.65]].map(([x, y], i) => (
<polygon key={i} points={STAR} transform={`translate(${x},${y}) scale(0.58)`} />
))}
</g>
</>
),
/* 中国台湾:台湾地区无国家认可的区旗,用红底白字「台」作区划标识 */
TWD: (
<>
<rect width="24" height="16" fill="#de2910" />
<text x="12" y="12.3" textAnchor="middle" fontSize="11.5" fontWeight="700" fill="#ffffff">台</text>
</>
),
/* 澳大利亚:蓝底 + 米字旗区 + 南十字星 */
AUD: (
<>
<rect width="24" height="16" fill="#00247d" />
<g>
<path d="M0 0 L12 8 M12 0 L0 8" stroke="#ffffff" strokeWidth="1.9" />
<path d="M0 0 L12 8 M12 0 L0 8" stroke="#cf142b" strokeWidth="1" />
<path d="M6 0 V8 M0 4 H12" stroke="#ffffff" strokeWidth="2.8" />
<path d="M6 0 V8 M0 4 H12" stroke="#cf142b" strokeWidth="1.6" />
</g>
<g fill="#ffffff">
<polygon points={STAR} transform="translate(5.6,12.1) scale(1.5)" />
<polygon points={STAR} transform="translate(17.6,4.6) scale(0.72)" />
<polygon points={STAR} transform="translate(20.3,7.7) scale(0.72)" />
<polygon points={STAR} transform="translate(17.7,10.7) scale(0.72)" />
<polygon points={STAR} transform="translate(15,7.7) scale(0.6)" />
<polygon points={STAR} transform="translate(19.2,8.6) scale(0.48)" />
</g>
</>
),
/* 阿联酋:左侧红竖条 + 绿白黑三横条 */
AED: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect width="24" height="5.33" fill="#00732f" />
<rect y="10.67" width="24" height="5.33" fill="#000000" />
<rect width="6" height="16" fill="#ff0000" />
</>
),
/* 沙特:绿底 + 白色清真言与宝刀(简形) */
SAR: (
<>
<rect width="24" height="16" fill="#006c35" />
<g fill="#ffffff">
<rect x="3.4" y="4.1" width="17.2" height="1.5" rx="0.75" />
<rect x="3.4" y="6.5" width="14" height="1.5" rx="0.75" />
<rect x="3.4" y="10.9" width="14.6" height="1.25" rx="0.6" />
<polygon points="18.7,10.25 22.4,11.5 18.7,12.8" />
</g>
</>
),
/* 加拿大:两侧红竖条 + 中间红枫叶 */
CAD: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect width="6" height="16" fill="#d80621" />
<rect x="18" width="6" height="16" fill="#d80621" />
<path
d="M50 5 L58 30 L75 25 L68 45 L90 42 L72 58 L95 72 L62 68 L68 95 L50 78 L32 95 L38 68 L5 72 L28 58 L10 42 L32 45 L25 25 L42 30 Z"
fill="#d80621"
transform="translate(12,8) scale(0.15) translate(-50,-50)"
/>
</>
)
}
/**
* @param {string} code ISO 4217 三字币种代码
* @param {number} width 显示宽度(px),高度按 3:2 自动算
*/
export default function CurrencyFlag({ code, width = 21 }) {
const body = FLAGS[code]
const height = Math.round((width * 16) / 24)
if (!body) {
return <span className="fx-flag is-blank" style={{ width, height }} />
}
return (
<svg
className="fx-flag"
viewBox="0 0 24 16"
width={width}
height={height}
aria-hidden="true"
focusable="false"
>
{body}
</svg>
)
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect } from 'react'
import { CloseIcon } from './Icons'
/**
* 右侧抽屉。产品页原本各自内联了一份同样的结构,这里抽出来给新页面复用,
* 保持「遮罩点击关闭 / Esc 关闭 / 正文独立滚动」三件事的行为一致。
*/
function Drawer({ title, sub, onClose, children, footer, wide = false }) {
useEffect(() => {
const onKeyDown = (event) => { if (event.key === 'Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className={`drawer${wide ? ' drawer-wide' : ''}`} role="dialog" aria-modal="true" aria-label={title}>
<div className="drawer-header">
<div className="drawer-head-main">
<h2 className="drawer-title">{title}</h2>
{sub && <p className="drawer-sub">{sub}</p>}
</div>
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<div className="drawer-body">{children}</div>
{footer && <div className="drawer-footer">{footer}</div>}
</aside>
</>
)
}
export default Drawer
+48 -3
View File
@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { CalculatorIcon, ChevronDownIcon, RefreshIcon, SwapIcon } from './Icons'
import CurrencyFlag from './CurrencyFlag'
import {
buildCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
buildPrimaryCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
} from '../currencies'
import { amountToWords } from '../amountInWords'
const DEFAULT_FROM = 'USD'
const DEFAULT_TO = 'CNY'
@@ -41,9 +43,16 @@ function CurrencyPicker({ value, options, onChange, align }) {
const filtered = useMemo(() => {
const term = keyword.trim().toUpperCase()
if (!term) return options
return options.filter(item => item.code.includes(term) || (item.name && item.name.includes(keyword.trim())))
const raw = keyword.trim()
return options.filter(item =>
item.code.includes(term)
|| (item.name && item.name.includes(raw))
|| (item.region && item.region.includes(raw))
)
}, [options, keyword])
const labelOf = item => `${item.code} ${item.name}${item.region ? ` · ${item.region}` : ''}`
return (
<div className={`fx-picker ${align}`} ref={boxRef}>
<button
@@ -54,6 +63,7 @@ function CurrencyPicker({ value, options, onChange, align }) {
aria-haspopup="listbox"
aria-expanded={open}
>
<CurrencyFlag code={value} width={20} />
<b>{value}</b>
<span>{currencyLabel(value).slice(4) || '—'}</span>
<ChevronDownIcon size={13} />
@@ -78,9 +88,11 @@ function CurrencyPicker({ value, options, onChange, align }) {
type="button"
role="option"
aria-selected={item.code === value}
title={labelOf(item)}
className={`fx-picker-item${item.code === value ? ' active' : ''}`}
onClick={() => { onChange(item.code); setOpen(false) }}
>
<CurrencyFlag code={item.code} width={20} />
<b>{item.code}</b>
<span>{item.name || '—'}</span>
</button>
@@ -105,6 +117,7 @@ function FxCalculator() {
/* 只存「用户正在输入的那一侧」的原始文本,另一侧永远是算出来的。
这样不存在两个输入框互相改、改出抖动的可能。 */
const [entry, setEntry] = useState({ value: '1000', side: 'from' })
const [copied, setCopied] = useState(false)
const panelRef = useRef(null)
const loadedRef = useRef(false)
@@ -164,7 +177,8 @@ function FxCalculator() {
try { localStorage.setItem(PAIR_STORAGE_KEY, JSON.stringify(pair)) } catch { /* 忽略 */ }
}, [pair])
const options = useMemo(() => buildCurrencyOptions(rates, [DEFAULT_FROM, DEFAULT_TO, 'EUR', 'GBP', 'JPY']), [rates])
/* 只给主流 13 种币种(见 currencies.js 的 PRIMARY_CURRENCIES),不跟随接口的全量列表 */
const options = useMemo(() => buildPrimaryCurrencyOptions(), [])
const rate = crossRate(rates, from, to) // 1 from = ? to
const inverse = crossRate(rates, to, from) // 1 to = ? from
const num = parseAmount(entry.value)
@@ -200,6 +214,23 @@ function FxCalculator() {
? new Date(meta.updatedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: ''
/* 英文大写金额跟随左侧(from)的金额与币种 —— 单证上写的就是报价币种那一侧 */
const words = useMemo(() => {
const value = parseAmount(fromDisplay)
return value === null ? '' : amountToWords(value, from)
}, [fromDisplay, from])
const copyWords = async () => {
if (!words) return
try {
await navigator.clipboard.writeText(words)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch {
/* 剪贴板不可用(非安全上下文等)时不打断使用,手动选中也能复制 */
}
}
return (
<div className="fx-calc" ref={panelRef}>
<button
@@ -271,6 +302,20 @@ function FxCalculator() {
: '—'}
</div>
<div className="fx-words">
<div className="fx-words-head">
<span className="fx-words-label">英文大写金额</span>
{words && (
<button type="button" className="fx-copy" onClick={copyWords}>
{copied ? '已复制' : '复制'}
</button>
)}
</div>
<p className={`fx-words-text${words ? '' : ' is-empty'}`}>
{words || '在左侧输入金额后自动生成'}
</p>
</div>
<div className="fx-panel-foot">
{meta?.warning
? <span className="fx-warn">{meta.warning}</span>
+46 -10
View File
@@ -1,24 +1,29 @@
import React, { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios'
import { BotIcon, CloudDbIcon, PlusCircleIcon, SearchIcon } from './Icons'
import { BotIcon, CloudDbIcon, PlusCircleIcon, SearchIcon, TrashIcon } from './Icons'
import { useAuth, roleLabel } from '../auth'
import { useBranding } from '../branding'
import TimeZoneClock from './TimeZoneClock'
import FxCalculator from './FxCalculator'
import { HOME_PATH, PAGE_LABELS } from '../navigation'
const TABS_STORAGE_KEY = 'happy.openTabs'
const MAX_TABS = 10
/* 工作台恒定占第一位,后面最多再挂 4 个页面 */
const MAX_TAB_PAGES = 4
const MAX_TABS = MAX_TAB_PAGES + 1
/* 从本地缓存读取上次打开的页面(只保留仍然存在的路由,工作台恒定在首位) */
function readStoredTabs() {
try {
const raw = JSON.parse(localStorage.getItem(TABS_STORAGE_KEY) || '[]')
const list = Array.isArray(raw) ? raw.filter(path => PAGE_LABELS[path]) : []
return [HOME_PATH, ...list.filter(path => path !== HOME_PATH)]
const list = Array.isArray(raw) ? raw.filter(path => PAGE_LABELS[path] && path !== HOME_PATH) : []
return [HOME_PATH, ...list.slice(0, MAX_TAB_PAGES)]
} catch { return [HOME_PATH] }
}
function GlobalHeader({ onOpenAssistant }) {
function GlobalHeader({ onOpenAssistant, onNavigate }) {
const { user } = useAuth()
const [now, setNow] = useState(new Date())
const [searchOpen, setSearchOpen] = useState(false)
const [query, setQuery] = useState('')
@@ -59,7 +64,16 @@ function GlobalHeader({ onOpenAssistant }) {
const next = openTabs.filter(item => item !== path)
const result = next.length ? next : [HOME_PATH]
setOpenTabs(result)
if (path === location.pathname) navigate(result[0])
if (path === location.pathname) {
/* 退到「最近使用过的另一个页面」,全关光了才回工作台 */
navigate(result.find(item => item !== HOME_PATH) || HOME_PATH)
}
}
/* 清空快捷标签:工作台常驻不动,其余一次性摘掉;当前页若被清掉就回工作台 */
const clearTabs = () => {
setOpenTabs([HOME_PATH])
if (location.pathname !== HOME_PATH && PAGE_LABELS[location.pathname]) navigate(HOME_PATH)
}
useEffect(() => {
@@ -88,11 +102,22 @@ function GlobalHeader({ onOpenAssistant }) {
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const selectResult = result => { navigate(result.path); setQuery(''); setSearchOpen(false) }
/* 页内所有跳转都走外壳的 guardedNavigate:个人资料有未保存改动时会先弹确认 */
const go = React.useCallback(path => {
if (onNavigate) onNavigate(path)
else navigate(path)
}, [onNavigate, navigate])
const selectResult = result => { go(result.path); setQuery(''); setSearchOpen(false) }
const displayName = user?.display_name || user?.username || '未登录'
const brand = useBranding()
const positionText = user?.position || roleLabel(user?.role)
const avatarText = (displayName || '?').slice(0, 1).toUpperCase()
return (
<header className="global-header">
<div className="global-header-brand"><span className="brand-mark" title="happy出口通"><CloudDbIcon size={36} /><span className="header-status-dot" /></span><div><strong>happy出口通</strong><span>EXPORT OPERATIONS CONSOLE</span></div></div>
<div className="global-header-brand"><span className="brand-mark" title={brand.name}><CloudDbIcon size={36} /><span className="header-status-dot" /></span><div><strong>{brand.name}</strong><span>{brand.subtitle}</span></div></div>
<div className={`global-search ${searchOpen ? 'is-open' : ''}`}>
<SearchIcon size={17} />
<input type="text" ref={searchRef} aria-label="搜索 everything" value={query} onFocus={() => setSearchOpen(true)} onChange={event => setQuery(event.target.value)} placeholder="搜索 everything" autoComplete="off" />
@@ -110,13 +135,24 @@ function GlobalHeader({ onOpenAssistant }) {
const active = location.pathname === path
return (
<div key={path} className={`page-tab${active ? ' active' : ''}`}>
<button type="button" className="page-tab-label" title={label} aria-current={active ? 'page' : undefined} onClick={() => navigate(path)}>{label}</button>
<button type="button" className="page-tab-label" title={label} aria-current={active ? 'page' : undefined} onClick={() => go(path)}>{label}</button>
{!isHome && <button type="button" className="page-tab-close" aria-label={`关闭${label}`} title={`关闭${label}`} onClick={event => closeTab(event, path)}>×</button>}
</div>
)
})}
</nav>
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开小皮"><BotIcon size={19} /><span>小皮</span><i>β</i></button><button className="header-avatar" title="当前用户">W</button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
{openTabs.length > 1 && (
<button
type="button"
className="page-tabs-clear"
onClick={clearTabs}
aria-label="清空快捷标签"
title="清空快捷标签(工作台保留)"
>
<TrashIcon size={14} />
</button>
)}
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开小皮"><BotIcon size={19} /><span>小皮</span><i>β</i></button><button type="button" className="header-user" onClick={() => go('/profile')} title={`${displayName} · ${positionText} · 个人资料`} aria-label="个人资料"><span className="header-avatar">{user?.avatar ? <img src={user.avatar} alt="" /> : avatarText}</span><span className="header-user-text"><b>{displayName}</b><small>{positionText}</small></span></button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
</header>
)
}
+160
View File
@@ -191,6 +191,23 @@ export const PlusCircleIcon = ({ size = 20, className = '' }) => (
export const BotIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><rect x="4" y="7" width="16" height="12" rx="3" /><path d="M12 3v4M8 12h.01M16 12h.01M8 16h8" /><path d="M2 12h2M20 12h2" /></svg>
)
/* 小皮专属标志 —— 跟 lucide 通用 BotIcon 区分:
* - 头部加 16% 浅填充(视觉重量更足,缩略尺寸下也认得出)
* - 眼睛从「点」改「实心圆」(更可爱)
* - 嘴巴从「横线」改「弧线」(更友善)
* - 去掉 lucide Bot 两侧那两根短横线(视觉上像「耳朵」又不像,反而显得莫名)
* - 头顶触角末端加一颗实心小星点(暗示 AI) */
export const AIBotIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="4" y="9" width="16" height="11" rx="3.5" fill="currentColor" fillOpacity="0.16" />
<path d="M12 5v4" />
<circle cx="12" cy="4.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="9" cy="14" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="14" r="1.2" fill="currentColor" stroke="none" />
<path d="M9.5 17.2c.6.7 4.4.7 5 0" />
</svg>
)
export const SendIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
)
@@ -244,6 +261,22 @@ export const QuoteIcon = ({ size = 20, className = '' }) => (
</svg>
)
/* 询盘:来信气泡 + 内含报价行 */
export const InquiryIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M21 12.4a7.6 7.6 0 0 1-7.6 7.6H8.4L3 22.2V8.8A7.6 7.6 0 0 1 10.6 1.2h2.8A7.6 7.6 0 0 1 21 8.8z" />
<path d="M8 8.6h8M8 12h5" />
</svg>
)
/* 合同清洗:漏斗过滤 + 落料 */
export const CleanupIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M3 4.4h18l-7.2 8.4v6.2l-3.6 2.2v-8.4z" />
<path d="M6.6 8.1h10.8" />
</svg>
)
/* PI 合同(形式发票 / 合同文件) */
export const ContractIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
@@ -329,3 +362,130 @@ export const PencilIcon = ({ size = 16, className = '' }) => (
<path d="m14.7 5.5 3.8 3.8" />
</svg>
)
/* 返回 / 左箭头 */
export const ArrowLeftIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M19 12H5" />
<path d="m11 18-6-6 6-6" />
</svg>
)
/* 盾牌 / 权限 */
export const ShieldIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 3l7 3v6c0 4.3-2.9 7.8-7 9-4.1-1.2-7-4.7-7-9V6z" />
<path d="m9 12 2.2 2.2L15.5 10" />
</svg>
)
/* 地球 / 公海 */
export const GlobeIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3.2 9h17.6M3.2 15h17.6" />
<path d="M12 3c2.4 2.5 3.6 5.5 3.6 9s-1.2 6.5-3.6 9c-2.4-2.5-3.6-5.5-3.6-9S9.6 5.5 12 3z" />
</svg>
)
/* 单人 / 个人资料 */
export const UserIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<circle cx="12" cy="8.2" r="3.7" />
<path d="M4.6 20c.6-3.6 3.6-5.6 7.4-5.6s6.8 2 7.4 5.6" />
</svg>
)
/* 钥匙 / 密码 */
export const KeyIcon = ({ size = 18, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<circle cx="8" cy="15" r="4" />
<path d="m11 12 8.5-8.5M17 6l2 2M14.5 8.5l2 2" />
</svg>
)
/* 建筑 / 组织 */
export const BuildingIcon = ({ size = 18, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M4 21V5.2A1.2 1.2 0 0 1 5.2 4h8.6a1.2 1.2 0 0 1 1.2 1.2V21" />
<path d="M15 10h3.8A1.2 1.2 0 0 1 20 11.2V21M2.5 21h19" />
<path d="M7.5 8h4M7.5 12h4M7.5 16h4" />
</svg>
)
/* 保存 / 勾选存盘 */
export const SaveIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M5 3.8h11L20.2 8v12.2A1.8 1.8 0 0 1 18.4 22H5a1.8 1.8 0 0 1-1.8-1.8V5.6A1.8 1.8 0 0 1 5 3.8z" />
<path d="M7.6 3.8v5.4h7.6V3.8M7.6 22v-6.2h8.8V22" />
</svg>
)
/* 登出 */
export const LogoutIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M14.5 4.5H6.8A1.8 1.8 0 0 0 5 6.3v11.4a1.8 1.8 0 0 0 1.8 1.8h7.7" />
<path d="M18 12H10M15.4 8.6 18.8 12l-3.4 3.4" />
</svg>
)
/* 相机 / 换头像 */
export const CameraIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M3.5 8.4h3l1.4-2.2h6.2L15.5 8.4h4A1.5 1.5 0 0 1 21 9.9v8.2a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.1V9.9a1.5 1.5 0 0 1 1.5-1.5z" />
<circle cx="12" cy="13.6" r="3.2" />
</svg>
)
/* 眼睛 / 显示密码 */
export const EyeIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
/* 眼睛(划线)/ 隐藏密码 */
export const EyeOffIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M4 4l16 16" />
<path d="M9.6 5.9A9.6 9.6 0 0 1 12 5.8c6 0 9.5 6.2 9.5 6.2a17 17 0 0 1-2.7 3.5M6.4 8.2A16.8 16.8 0 0 0 2.5 12S6 18.2 12 18.2c1.3 0 2.4-.3 3.4-.7" />
<path d="M10 10.2a3 3 0 0 0 4 4" />
</svg>
)
/* 加号 / 新增 */
export const PlusIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
)
/* 上传 / 上传者 */
export const UploadIcon = ({ size = 14, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 16V4" />
<path d="m7.5 8.5 4.5-4.5 4.5 4.5" />
<path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15" />
</svg>
)
/* 副本 / 复制 */
export const CopyIcon = ({ size = 14, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M15 6.5V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.5" />
</svg>
)
/* 操作人 / 用户痕迹 */
export const FingerprintIcon = ({ size = 14, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 4a8 8 0 0 0-8 8v3" />
<path d="M20 15v-3a8 8 0 0 0-4-6.93" />
<path d="M12 11a1.5 1.5 0 0 0-1.5 1.5V17a3 3 0 0 0 3 3" />
<path d="M16 17v-4.5a4 4 0 0 0-2-3.46" />
<path d="M7 17.5V12a5 5 0 0 1 7.5-4.33" />
</svg>
)
@@ -0,0 +1,145 @@
import React, { useCallback, useEffect, useState } from 'react'
import axios from 'axios'
import { CheckIcon, CloseIcon, RefreshIcon, SaveIcon, UsersIcon } from './Icons'
/**
* 组织与品牌 —— 只管「公司 / 组织」这块**可改**的业务信息。
*
* 界面上的系统名(登录页、顶栏、浏览器标题)是作者固定署名,由后端
* `lib/branding.js` 提供:设置页不展示、也没有修改入口,改公司名也不会顶掉它。
* (Wayne 明确要求设置页里不要出现系统标识展示与任何解释文字,别再往这加。)
*/
function OrgBrandingSettings() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [orgs, setOrgs] = useState([])
const [orgDraft, setOrgDraft] = useState({})
const [busy, setBusy] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await axios.get('/api/organizations')
const list = data?.organizations || []
setOrgs(list)
const draft = {}
for (const org of list) {
draft[org.id] = { name: org.name || '', code: org.code || '', description: org.description || '' }
}
setOrgDraft(draft)
setError('')
} catch (err) {
setError(err?.response?.data?.error || '读取组织信息失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const flash = (text) => {
setNotice(text)
window.setTimeout(() => setNotice(''), 3500)
}
const saveOrg = async (id) => {
setBusy(`org:${id}`)
try {
const { data } = await axios.patch(`/api/organizations/${id}`, orgDraft[id])
setOrgs(current => current.map(org => (org.id === id ? { ...org, ...data.organization } : org)))
flash(`公司名已改为「${data.organization.name}」`)
} catch (err) {
setError(err?.response?.data?.error || '保存组织信息失败')
} finally {
setBusy('')
}
}
if (loading) {
return (
<div className="settings-section">
<h3>组织与品牌</h3>
<p className="settings-description">正在读取组织信息…</p>
</div>
)
}
return (
<div className="settings-section">
<h3>组织与品牌</h3>
<p className="settings-description">
公司名可以随时改;界面上的系统名由作者固定署名,不随公司名变化。
</p>
{notice && <p className="ai-banner is-ok"><CheckIcon size={14} /> {notice}</p>}
{error && <p className="ai-banner is-error"><CloseIcon size={14} /> {error}</p>}
<div className="settings-group">
<div className="ai-group-head">
<h4>公司 / 组织</h4>
<button className="btn btn-secondary btn-sm" onClick={load} disabled={Boolean(busy)}>
<RefreshIcon size={14} /> 刷新
</button>
</div>
<p className="help-text">
<UsersIcon size={13} /> 同事归属哪个组织,就与同组织成员共享客户公海与产品库。
</p>
{orgs.length === 0 && <p className="help-text">还没有组织记录。</p>}
{orgs.map(org => (
<div className="org-card" key={org.id}>
<div className="form-row">
<div className="form-group">
<label>组织名称</label>
<input
type="text"
value={orgDraft[org.id]?.name ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], name: event.target.value }
}))}
/>
</div>
<div className="form-group">
<label>英文代码</label>
<input
type="text"
value={orgDraft[org.id]?.code ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], code: event.target.value }
}))}
placeholder="例如 HAPPY"
/>
</div>
</div>
<div className="form-group">
<label>描述</label>
<input
type="text"
value={orgDraft[org.id]?.description ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], description: event.target.value }
}))}
placeholder="这行说明会显示在权限组/用户的组织下拉里"
/>
</div>
<div className="org-card-foot">
<span className="help-text">{org.member_count || 0} 名成员</span>
<button
className="btn btn-primary btn-sm"
onClick={() => saveOrg(org.id)}
disabled={Boolean(busy) || orgDraft[org.id]?.name === org.name}
>
<SaveIcon size={14} /> {busy === `org:${org.id}` ? '保存中…' : '保存公司信息'}
</button>
</div>
</div>
))}
</div>
</div>
)
}
export default OrgBrandingSettings
+263
View File
@@ -0,0 +1,263 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import { PackageIcon, RefreshIcon } from './Icons'
/* 常用柜型内容积(m³)。取行业常用的保守值,宁少不溢柜。 */
const CONTAINERS = [
{ key: '20GP', label: '20GP', volume: 28 },
{ key: '40GP', label: '40GP', volume: 58 },
{ key: '40HQ', label: '40HQ', volume: 68 }
]
const FIELDS = [
{ key: 'inner_pack_qty', label: '内盒装量', unit: 'pcs/内盒', group: 'pack' },
{ key: 'carton_qty', label: '外箱装量', unit: 'pcs/箱', group: 'pack' },
{ key: 'carton_length', label: '外箱长', unit: 'cm', group: 'carton' },
{ key: 'carton_width', label: '外箱宽', unit: 'cm', group: 'carton' },
{ key: 'carton_height', label: '外箱高', unit: 'cm', group: 'carton' },
{ key: 'carton_weight', label: '外箱毛重', unit: 'kg', group: 'carton' },
{ key: 'unit_length', label: '单品长', unit: 'cm', group: 'unit' },
{ key: 'unit_width', label: '单品宽', unit: 'cm', group: 'unit' },
{ key: 'unit_height', label: '单品高', unit: 'cm', group: 'unit' },
{ key: 'unit_weight', label: '单品净重', unit: 'kg', group: 'unit' }
]
const EMPTY = FIELDS.reduce((acc, field) => { acc[field.key] = ''; return acc }, { packaging_material: '', notes: '' })
/** 算外箱体积与各柜型装柜量;字段不全就返回 null,不猜。 */
function calcLoading(form) {
const l = Number(form.carton_length)
const w = Number(form.carton_width)
const h = Number(form.carton_height)
const perCarton = Number(form.carton_qty)
if (![l, w, h].every(value => Number.isFinite(value) && value > 0)) return null
const cartonVolume = (l * w * h) / 1000000
if (!(cartonVolume > 0)) return null
const rows = CONTAINERS.map(container => {
const cartons = Math.floor(container.volume / cartonVolume)
return {
...container,
cartons,
pieces: Number.isFinite(perCarton) && perCarton > 0 ? cartons * perCarton : null
}
})
let totalWeight = null
const gross = Number(form.carton_weight)
if (Number.isFinite(gross) && gross > 0) totalWeight = rows.map(row => row.cartons * gross)
return { cartonVolume, rows, totalWeight }
}
/** 把接口返回的对象转成受控表单值(null → 空串,避免 React 受控告警) */
function toForm(data) {
const next = { ...EMPTY }
FIELDS.forEach(field => {
const value = data?.[field.key]
next[field.key] = value === null || value === undefined ? '' : String(value)
})
next.packaging_material = data?.packaging_material || ''
next.notes = data?.notes || ''
return next
}
/**
* 结构化包装信息面板。
* 只负责给「包装」这块数据提供录入与展示,SKU 是否存在由调用方保证
* (后端 PUT 会自己向 SKU 服务核验一次)。
*/
function PackagingPanel({ sku, readOnly = false, onSaved, canEdit }) {
const [form, setForm] = useState(EMPTY)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [available, setAvailable] = useState(true)
const load = useCallback(async () => {
if (!sku) return
setLoading(true)
setError('')
try {
const { data } = await axios.get(`/api/products/${encodeURIComponent(sku)}/packaging`)
setForm(toForm(data))
setAvailable(true)
} catch (loadError) {
setAvailable(false)
setError(loadError?.response?.data?.error || '读取包装信息失败')
} finally {
setLoading(false)
}
}, [sku])
useEffect(() => { load() }, [load])
useEffect(() => { setNotice('') }, [sku])
const loadingPlan = useMemo(() => calcLoading(form), [form])
// 空白输入一律按「未填」处理,不要变成 0 —— 0 和没填在装柜测算里含义完全不同
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
const save = async (event) => {
event.preventDefault()
if (!sku) return
setSaving(true)
setError('')
setNotice('')
const payload = {}
FIELDS.forEach(field => {
const raw = form[field.key]
payload[field.key] = raw === '' ? null : Number(raw)
})
payload.packaging_material = form.packaging_material.trim() || null
payload.notes = form.notes.trim() || null
try {
const { data } = await axios.put(`/api/products/${encodeURIComponent(sku)}/packaging`, payload)
setForm(toForm(data.packaging))
setNotice('包装信息已保存')
if (onSaved) onSaved(data.packaging)
} catch (saveError) {
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
if (loading) return <p className="pkg-loading">读取包装信息…</p>
if (!available) {
return (
<div className="pkg-unavailable">
<p>{error}</p>
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 重试
</button>
</div>
)
}
return (
<form className="pkg-panel" onSubmit={save}>
<div className="pkg-section">
<h4 className="pkg-section-title"><PackageIcon size={14} /> 装箱结构</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'pack').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="1" inputMode="numeric"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">外箱尺寸与重量</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'carton').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="0.1" inputMode="decimal"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">单品尺寸与重量</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'unit').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="0.1" inputMode="decimal"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">包装与备注</h4>
<div className="pkg-grid">
<label className="pkg-field pkg-field-wide">
<span>包装材质</span>
<input
type="text" value={form.packaging_material} disabled={readOnly}
placeholder="例如:彩盒 + 五层瓦楞纸箱"
onChange={event => set('packaging_material', event.target.value)}
/>
</label>
<label className="pkg-field pkg-field-wide">
<span>包装备注</span>
<input
type="text" value={form.notes} disabled={readOnly}
placeholder="唛头要求、客户指定箱规等"
onChange={event => set('notes', event.target.value)}
/>
</label>
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">装柜测算</h4>
{!loadingPlan ? (
<p className="pkg-loading-hint">填好外箱长宽高即可自动推算各柜型装柜量。</p>
) : (
<>
<p className="pkg-volume">
单箱体积 <b>{loadingPlan.cartonVolume.toFixed(4)}</b> m³
</p>
<div className="pkg-container-grid">
{loadingPlan.rows.map((row, index) => (
<div className="pkg-container" key={row.key}>
<span className="pkg-container-name">{row.label}</span>
<span className="pkg-container-main">
<b>{row.cartons.toLocaleString('zh-CN')}</b> 箱
</span>
<span className="pkg-container-sub">
{row.pieces === null ? '装量未知' : `${row.pieces.toLocaleString('zh-CN')} pcs`}
{loadingPlan.totalWeight ? ` · 毛重 ${(loadingPlan.totalWeight[index] / 1000).toFixed(2)} t` : ''}
</span>
</div>
))}
</div>
<p className="pkg-caveat">按柜内容积 28 / 58 / 68 m³ 估算,未计托盘与装载空隙,实际订舱请以货代确认为准。</p>
</>
)}
</div>
{error && <p className="pkg-error">{error}</p>}
{notice && <p className="pkg-notice">{notice}</p>}
{!readOnly && canEdit && (
<div className="pkg-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load} disabled={saving}>
<RefreshIcon size={14} /> 还原
</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? '保存中…' : '保存包装信息'}
</button>
</div>
)}
</form>
)
}
export default PackagingPanel
+260
View File
@@ -0,0 +1,260 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { SearchIcon, ChevronDownIcon, CloseIcon } from './Icons'
/**
* 可搜索下拉(combobox)
*
* 供应商 272 家、产品 300+ 个,原生 <select> 只能一格一格滚。这里把触发区做成
* 一个搜索框:点开就能打字,候选实时收窄,↑↓ 选择、回车确认、Esc 收起。
*
* 实现要点:
* - 菜单用 createPortal 挂到 body + position:fixed —— 抽屉的 .drawer-body 是
* overflow:auto,就地绝对定位会被它裁掉;fixed 还能跟着滚动/缩放重新定位。
* - 触发区沿用全局「无方框」输入框规则:靠底色区分,不做描边与聚焦光环。
* - 展开时按实时位置决定朝上还是朝下弹,空间不够就压 max-height 自身滚动。
*
* props
* value / onChange(value, option)
* options [{ value, label, hint? }],hint 显示在右侧(灰)
* placeholder 未选中时的提示
* searchPlaceholder 展开后的提示
* emptyText 无匹配时的文案
* size 'md'(38px) | 'sm'(32px)
* clearable 选中后是否给出清除按钮
*/
const MENU_MAX = 268
const GAP = 6
function SearchSelect({
value,
onChange,
options = [],
placeholder = '请选择',
searchPlaceholder = '输入关键词搜索…',
emptyText = '没有匹配项',
size = 'md',
disabled = false,
clearable = false,
style,
className = ''
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const [pos, setPos] = useState(null)
const rootRef = useRef(null)
const menuRef = useRef(null)
const inputRef = useRef(null)
const listRef = useRef(null)
const selected = useMemo(
() => options.find((o) => String(o.value) === String(value ?? '')) || null,
[options, value]
)
const filtered = useMemo(() => {
const kw = query.trim().toLowerCase()
if (!kw) return options
return options.filter((o) => `${o.label || ''} ${o.hint || ''}`.toLowerCase().includes(kw))
}, [options, query])
/* 按触发区的实时位置定位菜单:下方空间不够就翻到上面 */
const place = useCallback(() => {
const el = rootRef.current
if (!el) return
const r = el.getBoundingClientRect()
const spaceBelow = window.innerHeight - r.bottom - GAP - 8
const spaceAbove = r.top - GAP - 8
const up = spaceBelow < 180 && spaceAbove > spaceBelow
setPos({
left: r.left,
width: r.width,
top: up ? undefined : r.bottom + GAP,
bottom: up ? window.innerHeight - r.top + GAP : undefined,
maxHeight: Math.max(120, Math.min(MENU_MAX, up ? spaceAbove : spaceBelow))
})
}, [])
const close = useCallback(() => {
setOpen(false)
setQuery('')
}, [])
const openMenu = useCallback(() => {
if (disabled || open) return
place()
setQuery('')
const idx = options.findIndex((o) => String(o.value) === String(value ?? ''))
setActive(idx >= 0 ? idx : 0)
setOpen(true)
}, [disabled, open, options, place, value])
const pick = useCallback((option) => {
onChange?.(option.value, option)
setOpen(false)
setQuery('')
}, [onChange])
/* 展开期间:点外部收起、滚动/缩放时跟住触发区 */
useEffect(() => {
if (!open) return
const onDocDown = (e) => {
if (rootRef.current?.contains(e.target)) return
if (menuRef.current?.contains(e.target)) return
close()
}
const onReflow = () => place()
document.addEventListener('mousedown', onDocDown)
window.addEventListener('resize', onReflow)
window.addEventListener('scroll', onReflow, true)
return () => {
document.removeEventListener('mousedown', onDocDown)
window.removeEventListener('resize', onReflow)
window.removeEventListener('scroll', onReflow, true)
}
}, [open, close, place])
/* 键盘走到看不见的项时,把它滚进视野 */
useEffect(() => {
if (!open) return
const el = listRef.current?.querySelector('.ss-opt.is-active')
el?.scrollIntoView({ block: 'nearest' })
}, [open, active])
const onKeyDown = (e) => {
// Esc 只收候选:必须拦住,否则抽屉/浮层的全局 Esc 会顺手把整层关掉
if (e.key === 'Escape') { e.stopPropagation(); close(); return }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (!open) { openMenu(); return }
if (filtered.length === 0) return
setActive((i) => {
const next = e.key === 'ArrowDown' ? i + 1 : i - 1
return (next + filtered.length) % filtered.length
})
return
}
if (e.key === 'Enter') {
if (!open) return
e.preventDefault()
const option = filtered[active]
if (option) pick(option)
}
}
const showClear = clearable && !disabled && selected
return (
<div
ref={rootRef}
className={`ss-root ss-${size}${open ? ' is-open' : ''}${disabled ? ' is-disabled' : ''}${className ? ` ${className}` : ''}`}
style={style}
onMouseDown={(e) => {
if (disabled) return
if (e.target.closest('.ss-clear')) return
if (inputRef.current && document.activeElement !== inputRef.current) inputRef.current.focus()
if (!open) openMenu()
}}
// mousedown 之外再兜一层 click:真实鼠标两者都会来(第二次是 no-op),
// 但程序化 el.click() / 触摸设备只发 click,不补这层会「点了没反应」
onClick={() => { if (!open) openMenu() }}
>
<span className="ss-icon" aria-hidden="true"><SearchIcon size={size === 'sm' ? 13 : 14} /></span>
<input
ref={inputRef}
type="text"
value={open ? query : (selected?.label || '')}
placeholder={open ? searchPlaceholder : placeholder}
readOnly={!open}
disabled={disabled}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
onFocus={openMenu}
onKeyDown={onKeyDown}
/>
{showClear && (
<button
type="button"
className="ss-clear"
title="清除"
aria-label="清除"
onMouseDown={(e) => e.preventDefault()}
onClick={() => { onChange?.('', null); setQuery(''); inputRef.current?.focus() }}
>
<CloseIcon size={12} />
</button>
)}
<span className={`ss-caret${open ? ' is-up' : ''}`} aria-hidden="true"><ChevronDownIcon size={13} /></span>
{open && pos && createPortal(
<div
ref={menuRef}
className="ss-menu"
role="listbox"
style={{ left: pos.left, width: pos.width, top: pos.top, bottom: pos.bottom, maxHeight: pos.maxHeight }}
>
{filtered.length === 0 ? (
<div className="ss-empty">{emptyText}</div>
) : (
<div ref={listRef}>
{filtered.map((o, i) => (
<div
key={String(o.value)}
role="option"
aria-selected={i === active}
className={`ss-opt${i === active ? ' is-active' : ''}${selected && String(o.value) === String(value ?? '') ? ' is-selected' : ''}`}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => { e.preventDefault(); pick(o) }}
onClick={() => pick(o)}
>
<span className="ss-label">{o.label}</span>
{o.hint != null && o.hint !== '' && <span className="ss-hint">{o.hint}</span>}
</div>
))}
</div>
)}
</div>,
document.body
)}
</div>
)
}
/**
* 列表上方的内联搜索框:用于「列表本身」的即时过滤(供应商详情的供货清单、
* 产品详情里的货号表这类)。和 SearchSelect 同一套外观语言。
*/
export function SearchField({ value, onChange, placeholder = '搜索…', size = 'md', style, className = '' }) {
return (
<div className={`sf-root sf-${size}${className ? ` ${className}` : ''}`} style={style}>
<span className="ss-icon" aria-hidden="true"><SearchIcon size={size === 'sm' ? 13 : 14} /></span>
<input
type="text"
value={value}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => onChange(e.target.value)}
/>
{value !== '' && value != null && (
<button
type="button"
className="ss-clear"
title="清除"
aria-label="清除"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange('')}
>
<CloseIcon size={12} />
</button>
)}
</div>
)
}
export default SearchSelect
+49 -40
View File
@@ -1,10 +1,34 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, Suspense } from 'react'
import axios from 'axios'
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon } from './Icons'
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon, BuildingIcon } from './Icons'
import { useAuth, hasPermission } from '../auth'
import { useBranding } from '../branding'
/* 这两块只有系统管理账号会看,按需加载:设置本身在外壳里是静态引入的,
跟着首屏一起下发等于给所有人多下载十几 KB。 */
const AIConfigSettings = React.lazy(() => import('./AIConfigSettings'))
const OrgBrandingSettings = React.lazy(() => import('./OrgBrandingSettings'))
/* 设置分类。带 permission 的项只对有该权限点的人显示 ——
智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。 */
const CATEGORIES = [
{ key: 'appearance', label: '外观与主题', icon: TuneIcon },
{ key: 'organization', label: '组织与品牌', icon: BuildingIcon, permission: 'branding.manage' },
{ key: 'import-export', label: '数据交换', icon: ExchangeIcon },
{ key: 'ai-config', label: '智能配置', icon: SparkIcon, permission: 'ai.manage' },
{ key: 'system-update', label: '系统与更新', icon: RefreshIcon }
]
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
const { user } = useAuth()
const [activeCategory, setActiveCategory] = useState('appearance')
const visibleCategories = CATEGORIES.filter(item => !item.permission || hasPermission(user, item.permission))
/* 打开着设置时权限被收回(换了角色),当前分类可能已经不可见,退回第一项 */
const current = visibleCategories.some(item => item.key === activeCategory)
? activeCategory
: visibleCategories[0]?.key
const modalRef = useRef(null)
useEffect(() => {
if (!isOpen) return
@@ -39,37 +63,36 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
<aside className="settings-sidebar">
<div className="settings-header">
<div>
<span className="section-label">[CONTROL CENTER]</span>
<h2 id="settings-title">系统设置</h2>
</div>
<button className="close-btn" onClick={onClose} aria-label="关闭设置"><CloseIcon size={18} /></button>
</div>
<nav className="settings-nav" aria-label="设置分类">
<button className={`settings-nav-item ${activeCategory === 'appearance' ? 'active' : ''}`} onClick={() => setActiveCategory('appearance')}>
<span className="settings-nav-icon"><TuneIcon size={16} /></span>
<span>外观与主题</span>
</button>
<button className={`settings-nav-item ${activeCategory === 'import-export' ? 'active' : ''}`} onClick={() => setActiveCategory('import-export')}>
<span className="settings-nav-icon"><ExchangeIcon size={18} /></span>
<span>数据交换</span>
</button>
<button className={`settings-nav-item ${activeCategory === 'ai-config' ? 'active' : ''}`} onClick={() => setActiveCategory('ai-config')}>
<span className="settings-nav-icon"><SparkIcon size={18} /></span>
<span>智能配置</span>
</button>
<button className={`settings-nav-item ${activeCategory === 'system-update' ? 'active' : ''}`} onClick={() => setActiveCategory('system-update')}>
<span className="settings-nav-icon"><RefreshIcon size={18} /></span>
<span>系统与更新</span>
</button>
{visibleCategories.map(item => {
const Icon = item.icon
return (
<button
key={item.key}
className={`settings-nav-item ${current === item.key ? 'active' : ''}`}
onClick={() => setActiveCategory(item.key)}
>
<span className="settings-nav-icon"><Icon size={17} /></span>
<span>{item.label}</span>
</button>
)
})}
</nav>
<div className="settings-sidebar-status"><TuneIcon size={15} /><span>专注业务,自在掌控<small>TRADE MANAGEMENT</small></span></div>
</aside>
<section className="settings-content">
{activeCategory === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
{activeCategory === 'import-export' && <ImportExportSettings />}
{activeCategory === 'ai-config' && <AIConfigSettings />}
{activeCategory === 'system-update' && <SystemUpdateSettings />}
<Suspense fallback={<div className="settings-section"><p className="settings-description">正在载入…</p></div>}>
{current === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
{current === 'organization' && <OrgBrandingSettings />}
{current === 'import-export' && <ImportExportSettings />}
{current === 'ai-config' && <AIConfigSettings />}
{current === 'system-update' && <SystemUpdateSettings />}
</Suspense>
</section>
</div>
</div>
@@ -79,7 +102,6 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
return (
<div className="settings-section">
<span className="section-label">[APPEARANCE]</span>
<h3>外观与主题</h3>
<p className="settings-description">选择适合工作环境的显示模式,设置会自动保存在本机。</p>
@@ -109,7 +131,6 @@ function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMoti
function ImportExportSettings() {
return (
<div className="settings-section">
<span className="section-label">[DATA EXCHANGE]</span>
<h3>导入 / 导出</h3>
<p className="settings-description">管理业务数据的导入、导出与本地备份。</p>
<div className="settings-group"><h4>导出数据</h4><div className="settings-actions"><button className="btn btn-secondary">导出供应商数据</button><button className="btn btn-secondary">导出产品数据</button><button className="btn btn-secondary">导出客户数据</button><button className="btn btn-secondary">导出订单数据</button></div></div>
@@ -119,19 +140,7 @@ function ImportExportSettings() {
)
}
function AIConfigSettings() {
return (
<div className="settings-section">
<span className="section-label">[INTELLIGENCE]</span>
<h3>智能配置</h3>
<p className="settings-description">配置 AI 功能相关参数与模型。</p>
<div className="settings-group"><h4>API 密钥配置</h4><div className="form-group"><label>OpenAI API Key</label><input type="password" placeholder="sk-..." className="form-input" /></div><div className="form-group"><label>Claude API Key</label><input type="password" placeholder="sk-ant-..." className="form-input" /></div><p className="help-text">当前为配置界面预览,密钥保存和连接测试尚未接入。</p></div>
<div className="settings-group"><h4>AI 功能开关</h4><label className="setting-toggle"><span><strong>启用合同智能解析</strong><small>自动提取合同关键字段</small></span><input type="checkbox" defaultChecked /><i /></label><label className="setting-toggle"><span><strong>启用客户画像分析</strong><small>辅助判断客户跟进优先级</small></span><input type="checkbox" defaultChecked /><i /></label><label className="setting-toggle"><span><strong>启用自动报价建议</strong><small>基于历史价格提供参考</small></span><input type="checkbox" /><i /></label></div>
<div className="settings-group"><h4>模型选择</h4><div className="form-group"><label>OCR 模型</label><select className="form-select"><option>GPT-4 Vision</option><option>Claude 3 Opus</option><option>Claude 3 Sonnet</option></select></div><div className="form-group"><label>文本分析模型</label><select className="form-select"><option>GPT-4</option><option>Claude 3 Opus</option><option>GPT-3.5 Turbo</option></select></div></div>
<div className="settings-actions"><button className="btn btn-primary">保存配置</button><button className="btn btn-secondary">测试连接</button></div>
</div>
)
}
/* 智能配置已拆到 ./AIConfigSettings(多模型接入 + 用途分配)与 ./OrgBrandingSettings(组织与品牌) */
/* ---------------------------------------------------------------------------
系统与更新
@@ -139,6 +148,7 @@ function AIConfigSettings() {
版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。
--------------------------------------------------------------------------- */
function SystemUpdateSettings() {
const brand = useBranding()
const [info, setInfo] = useState(null)
const [repo, setRepo] = useState('')
const [loadingInfo, setLoadingInfo] = useState(true)
@@ -194,7 +204,6 @@ function SystemUpdateSettings() {
return (
<div className="settings-section">
<span className="section-label">[SYSTEM]</span>
<h3>系统与更新</h3>
<p className="settings-description">查看当前版本与运行环境,并从官方仓库检查是否有新版本。</p>
@@ -202,7 +211,7 @@ function SystemUpdateSettings() {
<h4>当前版本</h4>
<div className="setting-info">
<span>
<strong>{info ? `happy 出口通 v${info.version}` : '正在读取版本…'}</strong>
<strong>{info ? `${brand.name} v${info.version}` : '正在读取版本…'}</strong>
<small>{info ? `运行环境 Node ${info.node} · ${info.platform} ${info.arch}` : '读取本机信息中'}</small>
</span>
<span className="preference-badge">正式版</span>
+96
View File
@@ -0,0 +1,96 @@
import React from 'react'
/* ---------------------------------------------------------------------------
* 骨架屏
*
* 为什么要有它:接口回来之前,页面原本只显示一句「正在载入…」。
* 用户看到的是「什么都没有」+ 一行字,主观上就是把整个加载时间都算成了等待。
* 换成与真实内容同构的灰色骨架后,视线先落在结构上,感知到的等待会短很多
* ——这不是玄学,是布局稳定(不会突然跳一下)带来的直接体感差别。
*
* 设计原则:
* · 骨架的行高、列宽必须**贴近真实内容**,否则加载完成时页面会大幅跳动,
* 那比显示一句「正在载入…」还难受。所以宁可少画几行,也不要画得不像。
* · 纯静态 + 透明度呼吸动画,不用渐变扫光(工业风不需要花活,且省 CPU)。
* · 尊重「减少动态效果」设置,开了就静止。
* --------------------------------------------------------------------------- */
/** 骨架基础块。宽高直接给数字(px)或字符串('60%')。 */
export default function Skeleton({ width = '100%', height = 12, radius = 4, className = '', style }) {
return (
<span
className={`skeleton-block ${className}`.trim()}
style={{ width, height, borderRadius: radius, ...style }}
aria-hidden="true"
/>
)
}
/**
* 表格骨架:必须放在 <tbody> 里。
* widths 传每列的相对宽度(数字数组),不传就均分。
* 第一列默认画长一点,因为表格第一列通常是名称/编号。
*/
export function SkeletonTableRows({ rows = 6, widths = null, colSpan = 1 }) {
const cells = widths || [2.2, 1, 1, 1]
const total = cells.reduce((sum, n) => sum + n, 0)
return Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="skeleton-row" aria-hidden="true">
{colSpan > 1 ? (
<td colSpan={colSpan} style={{ padding: '12px 14px' }}>
<span className="skeleton-inline">
{cells.map((weight, cellIndex) => (
<Skeleton
key={cellIndex}
width={`${((weight / total) * 100).toFixed(2)}%`}
height={12}
/>
))}
</span>
</td>
) : (
cells.map((weight, cellIndex) => (
<td key={cellIndex} style={{ padding: '12px 14px' }}>
<Skeleton width={`${Math.min(100, (weight / Math.max(...cells)) * 100).toFixed(0)}%`} height={12} />
</td>
))
)}
</tr>
))
}
/**
* 卡片网格骨架:给工作台那种「一排指标卡」用。
* 数量与真实卡片一致,避免加载完成时整页重排。
*/
export function SkeletonCards({ count = 4, height = 76 }) {
return (
<div className="skeleton-cards" aria-hidden="true">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="skeleton-card">
<Skeleton width="46%" height={11} />
<Skeleton width="68%" height={22} style={{ marginTop: 10 }} />
</div>
))}
</div>
)
}
/**
* 面板骨架:给「一张面板里几行文本」用。
*/
export function SkeletonPanel({ lines = 3, title = true }) {
return (
<div className="skeleton-panel" aria-hidden="true">
{title && <Skeleton width="30%" height={14} style={{ marginBottom: 14 }} />}
{Array.from({ length: lines }).map((_, index) => (
<Skeleton
key={index}
width={index === lines - 1 ? '55%' : '100%'}
height={12}
style={{ marginBottom: 10 }}
/>
))}
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { SearchIcon, PackageIcon, CloseIcon, ChevronDownIcon } from './Icons'
import { SkuThumb, useSkuThumbs } from './SkuThumb'
/**
* SKU 选择器
*
* 数据来自 happy 后端自己的 /api/sku(SKU 模块已并入),不直连端口。搜索返回的是「产品组」,
* 点开产品组再看到具体货号 —— 这是 SKU 服务本来的数据结构,硬拍平成一张 SKU 列表
* 会丢掉归属关系,业务上分不清同一个产品的不同规格。
*
* 同时允许直接输入完整货号回车选中:老手知道自己要哪个货号时不该被逼着点两层。
*/
function SkuPicker({ onPick, onClose, excludeSkus = [] }) {
const [keyword, setKeyword] = useState('')
const [groups, setGroups] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [expanded, setExpanded] = useState(null)
const [itemsCache, setItemsCache] = useState({})
const [itemsLoading, setItemsLoading] = useState(false)
const timerRef = useRef(null)
const excluded = useMemo(() => new Set(excludeSkus.map(sku => String(sku).toUpperCase())), [excludeSkus])
const search = useCallback(async (query) => {
setLoading(true)
setError('')
try {
const { data } = await axios.get('/api/sku/search', { params: { q: query } })
setGroups(Array.isArray(data) ? data : [])
} catch (searchError) {
setGroups([])
setError(searchError?.response?.data?.error || 'SKU 服务暂时不可用,请确认采购模块已启动')
} finally {
setLoading(false)
}
}, [])
// 首次直接拉全量(SKU 服务默认返回前 300 组),输入时再防抖查
useEffect(() => {
search('')
}, [search])
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => search(keyword.trim()), 260)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
}, [keyword, search])
const toggleGroup = async (group) => {
if (expanded === group.id) { setExpanded(null); return }
setExpanded(group.id)
if (itemsCache[group.id]) return
setItemsLoading(true)
try {
const { data } = await axios.get(`/api/sku/group/${group.id}`)
setItemsCache(current => ({ ...current, [group.id]: data.items || [] }))
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取产品货号失败')
} finally {
setItemsLoading(false)
}
}
// 输入框里直接敲完整货号时,优先按货号选中,不去猜产品组
const submitDirect = (event) => {
event.preventDefault()
const value = keyword.trim()
if (!value) return
onPick({ sku: value.toUpperCase(), display_sku: value.toUpperCase(), source: 'manual' })
}
return (
<div className="sku-picker">
<div className="sku-picker-head">
<h3><PackageIcon size={16} /> 选择已有货号</h3>
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭"><CloseIcon size={15} /></button>
</div>
<form className="sku-picker-search" onSubmit={submitDirect}>
<span className="sku-picker-search-icon"><SearchIcon size={15} /></span>
<input
type="text"
autoFocus
value={keyword}
placeholder="搜产品名 / 货号 / 规格,或直接敲完整货号回车"
aria-label="搜索货号"
onChange={event => setKeyword(event.target.value)}
/>
</form>
{error && <p className="sku-picker-error">{error}</p>}
<div className="sku-picker-body">
{loading && !groups.length && <p className="sku-picker-hint">正在读取 SKU 目录…</p>}
{!loading && !error && !groups.length && (
<p className="sku-picker-hint">没有匹配的产品。可以直接敲完整货号回车强制选用。</p>
)}
<ul className="sku-group-list">
{groups.map(group => {
const isOpen = expanded === group.id
const items = itemsCache[group.id] || []
const priceText = group.price_min === null || group.price_min === undefined
? '未报价'
: group.price_min === group.price_max
? `${group.price_min}`
: `${group.price_min} ~ ${group.price_max}`
return (
<li className="sku-group" key={group.id}>
<button
type="button"
className={`sku-group-head${isOpen ? ' is-open' : ''}`}
onClick={() => toggleGroup(group)}
aria-expanded={isOpen}
>
<span className={`sku-group-caret${isOpen ? ' is-open' : ''}`}><ChevronDownIcon size={13} /></span>
<span className="sku-group-name">{group.name || '未命名产品'}</span>
<span className="sku-group-meta">
{group.supplier_short || group.supplier_name || '未指派供应商'}
{' · '}
{group.item_count} 个货号
{' · '}
{priceText}
</span>
</button>
{isOpen && (
<div className="sku-group-items">
{itemsLoading && !items.length && <p className="sku-picker-hint">读取货号中…</p>}
{items.map(item => {
const already = excluded.has(String(item.sku).toUpperCase())
return (
<button
type="button"
key={item.sku}
className={`sku-item${already ? ' is-used' : ''}`}
disabled={already}
onClick={() => onPick(item)}
title={already ? '该货号已在本次询盘中' : `选用 ${item.sku}`}
>
<SkuThumb sku={item.sku} filename={thumbs[item.sku]} size={32} />
<span className="sku-item-code">{item.display_sku || item.sku}</span>
<span className="sku-item-spec">{item.spec || item.name || '—'}</span>
<span className="sku-item-price">
{item.price === null || item.price === undefined
? <em>未报价</em>
: <><b>{item.price}</b><span className="unit"> {item.currency || 'CNY'}</span></>}
</span>
{already && <span className="sku-item-flag">已添加</span>}
</button>
)
})}
{!itemsLoading && !items.length && <p className="sku-picker-hint">这个产品下还没有货号。</p>}
</div>
)}
</li>
)
})}
</ul>
</div>
</div>
)
}
export default SkuPicker
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import axios from 'axios'
import { ImageIcon } from './Icons'
/**
* 货号缩略图 + 鼠标悬浮预览。
*
* 图片来自 happy 后端(4300)的 /product-images/*,同源访问,已无独立 SKU 服务。
* 图片挂在**产品组**上(不是每个货号一张),所以同一个组里的货号会共享同一张主图。
*/
export const thumbSrc = filename => (filename ? `/product-images/${encodeURIComponent(filename)}` : '')
const PREVIEW_WIDTH = 268
const PREVIEW_HEIGHT = 268
/**
* @param {string} sku 货号(用于预览卡上的标注)
* @param {string} filename 图片文件名(不带路径)
* @param {number} size 缩略图边长
* @param {boolean} preview 是否开启悬浮预览
*/
export function SkuThumb({ sku, filename, size = 34, preview = true, className = '' }) {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState(null)
const anchorRef = useRef(null)
const src = thumbSrc(filename)
/* 预览是 fixed 浮层:滚动或改变视口就收起,免得悬在错位的半空中 */
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [open])
const show = () => {
const element = anchorRef.current
if (!element || !src) return
const rect = element.getBoundingClientRect()
let left = rect.right + 14
if (left + PREVIEW_WIDTH > window.innerWidth - 12) {
left = Math.max(12, rect.left - PREVIEW_WIDTH - 14)
}
let top = rect.top + rect.height / 2 - PREVIEW_HEIGHT / 2
top = Math.max(12, Math.min(top, window.innerHeight - PREVIEW_HEIGHT - 12))
setPosition({ left, top })
setOpen(true)
}
return (
<>
<span
ref={anchorRef}
className={`sku-thumb${src ? '' : ' is-empty'}${className ? ' ' + className : ''}`}
style={{ width: size, height: size }}
onMouseEnter={preview && src ? show : undefined}
onMouseLeave={preview ? () => setOpen(false) : undefined}
title={sku ? `货号 ${sku}` : undefined}
>
{src
? <img src={src} alt="" loading="lazy" />
: <ImageIcon size={Math.max(13, Math.round(size * 0.42))} />}
</span>
{open && position && createPortal(
<span className="sku-thumb-preview" style={{ left: position.left, top: position.top }}>
<img src={src} alt="" />
{sku ? <b>{sku}</b> : null}
</span>,
document.body
)}
</>
)
}
/**
* 批量取货号缩略图。
* 只关心「货号 → 文件名」这一层映射,一次请求拿一批,避免每个货号打一次接口。
*/
export function useSkuThumbs(skus) {
const [map, setMap] = useState({})
const key = useMemo(
() => [...new Set((skus || []).filter(Boolean).map(item => String(item)))].sort().join(','),
[skus]
)
useEffect(() => {
if (!key) { setMap({}); return }
let alive = true
axios.get('/api/sku/thumbs', { params: { skus: key } })
.then(({ data }) => { if (alive) setMap(data?.thumbs || {}) })
.catch(() => { if (alive) setMap({}) })
return () => { alive = false }
}, [key])
return map
}
export default SkuThumb