Artifact 88976fa682af99c645fb653e0b4a5dc5b13bc3324f8935024a76aa7a23f3be95:
- File
web/src/pages/Locations.tsx
— part of check-in
[dfa747c1ad]
at
2026-10-23 06:08:28
on branch trunk
— Web 管理控制台
- 用 Vite 和 React 做完整的 Web 端:登录、按权限显示菜单、切换当前作业仓库 - 覆盖来肯同步、仓库、库位规划、商品、销售订单、采购入库与批次码、采购订单、库存、用户授权和审计日志 - 开发时由 Vite 把 /api 代理到本机后端 - SQLite 写入和页面读取冲突时自动重试,避免请求进程因数据库忙碌直接崩溃 (user: pluto size: 7771)
import { useEffect, useState } from 'react' import { api, can, type Warehouse } from '../api' import { useSession } from '../session' import { Banner, Empty, Field, Page } from '../ui' type LocationRow = { code: string barcode: string enabled: boolean created_at: string } type PlanResult = { new_count: number existing_count: number locations: LocationRow[] existing: string[] } type Range = { from: string; to: string; width: string } const emptyRange = (): Range => ({ from: '1', to: '1', width: '2' }) export function Locations() { const { user } = useSession() const manage = can(user, 'location.manage') const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouseId, setWarehouseId] = useState(0) const [rows, setRows] = useState<LocationRow[]>([]) const [areas, setAreas] = useState('A') const [aisle, setAisle] = useState<Range>({ from: '1', to: '2', width: '2' }) const [level, setLevel] = useState<Range>(emptyRange()) const [bin, setBin] = useState<Range>({ from: '1', to: '2', width: '2' }) const [preview, setPreview] = useState<PlanResult | null>(null) const [error, setError] = useState('') const [ok, setOk] = useState('') useEffect(() => { api<{ warehouses: Warehouse[] }>('/api/warehouses') .then((data) => { setWarehouses(data.warehouses) const current = data.warehouses.find((row) => row.id === user?.current_warehouse_id) const first = current ?? data.warehouses.find((row) => row.operational) ?? data.warehouses[0] if (first) setWarehouseId(first.id) }) .catch((err: unknown) => setError(err instanceof Error ? err.message : '加载失败')) }, [user?.current_warehouse_id]) useEffect(() => { if (!warehouseId) return api<{ locations: LocationRow[] }>(`/api/locations?warehouse_id=${warehouseId}`) .then((data) => setRows(data.locations)) .catch((err: unknown) => setError(err instanceof Error ? err.message : '库位加载失败')) }, [warehouseId]) function body() { const range = (value: Range) => ({ from: Number(value.from), to: Number(value.to), width: Number(value.width || 2), }) return { warehouse_id: warehouseId, areas: areas.split(/[,,\s]+/).map((item) => item.trim()).filter(Boolean), aisle: range(aisle), level: range(level), bin: range(bin), } } function reload() { setWarehouseId((id) => id) api<{ locations: LocationRow[] }>(`/api/locations?warehouse_id=${warehouseId}`) .then((data) => setRows(data.locations)) .catch((err: unknown) => setError(err instanceof Error ? err.message : '库位加载失败')) } async function plan(commit: boolean) { setError('') setOk('') const payload = body() if (payload.areas.length === 0) { setError('至少填写一个库区') return } if (commit && !window.confirm(`确定生成 ${preview?.new_count ?? 0} 个新库位?`)) return try { const path = commit ? '/api/locations/plan' : '/api/locations/plan/preview' const result = await api<PlanResult>(path, { method: 'POST', body: JSON.stringify(payload), }) setPreview(result) if (commit) { setOk(`已生成 ${result.new_count} 个库位,跳过已有 ${result.existing_count} 个`) setPreview(null) reload() } } catch (err) { setError(err instanceof Error ? err.message : '规划失败') } } async function disable(code: string) { if (!window.confirm(`停用库位 ${code}?`)) return setError('') try { await api('/api/locations/disable', { method: 'POST', body: JSON.stringify({ warehouse_id: warehouseId, code }), }) reload() } catch (err) { setError(err instanceof Error ? err.message : '停用失败') } } const selected = warehouses.find((row) => row.id === warehouseId) return ( <Page title="库位"> <Banner kind="error" text={error} /> <Banner kind="ok" text={ok} /> <Field label="仓库"> <select value={warehouseId || ''} onChange={(event) => { setPreview(null) setWarehouseId(Number(event.target.value)) }} > {warehouses.map((row) => ( <option key={row.id} value={row.id}> {row.name}({row.code}){row.operational ? '' : ' · 仅显示'} </option> ))} </select> </Field> {manage && selected?.operational && ( <div className="panel"> <h2>按区间规划</h2> <p className="muted">编码为 库区-货架-层-位,例如 A-01-02-03。先预览,确认数量后再生成。</p> <Field label="库区,多个用逗号分隔"> <input value={areas} onChange={(event) => { setAreas(event.target.value); setPreview(null) }} /> </Field> <RangeEditor label="货架" value={aisle} onChange={(value) => { setAisle(value); setPreview(null) }} /> <RangeEditor label="层" value={level} onChange={(value) => { setLevel(value); setPreview(null) }} /> <RangeEditor label="位" value={bin} onChange={(value) => { setBin(value); setPreview(null) }} /> <div className="actions"> <button type="button" onClick={() => void plan(false)}>预览</button> <button type="button" disabled={!preview || preview.new_count === 0} onClick={() => void plan(true)}> 生成库位 </button> </div> {preview && ( <p> 将新建 {preview.new_count} 个,已存在 {preview.existing_count} 个。 {preview.locations.slice(0, 30).map((row) => row.code).join('、')} {preview.locations.length > 30 ? '…' : ''} </p> )} </div> )} {rows.length === 0 ? ( <Empty text="这个仓库还没有库位" /> ) : ( <div className="table-wrap"> <table> <thead> <tr> <th>库位</th> <th>条码</th> <th>状态</th> <th>建立时间</th> <th></th> </tr> </thead> <tbody> {rows.map((row) => ( <tr key={row.code}> <td className="mono">{row.code}</td> <td className="mono">{row.barcode}</td> <td> <span className={row.enabled ? 'tag ok' : 'tag off'}> {row.enabled ? '启用' : '停用'} </span> </td> <td>{row.created_at}</td> <td className="row-actions"> {manage && row.enabled && ( <button type="button" className="ghost" onClick={() => void disable(row.code)}> 停用 </button> )} </td> </tr> ))} </tbody> </table> </div> )} </Page> ) } function RangeEditor({ label, value, onChange, }: { label: string value: Range onChange: (value: Range) => void }) { return ( <div className="range-row"> <span>{label}</span> <input value={value.from} onChange={(event) => onChange({ ...value, from: event.target.value })} /> <span>到</span> <input value={value.to} onChange={(event) => onChange({ ...value, to: event.target.value })} /> <span>位数</span> <input value={value.width} onChange={(event) => onChange({ ...value, width: event.target.value })} /> </div> ) }