feat: 组织隔离(IRIS 单实例)与出料功能基础
本轮之前累积的未提交工作,一并固化:
- 组织隔离:同一份代码部署给不同部门只需改 config 的 ORG_DEPARTMENT 与
MATERIAL_CATEGORY_PREFIX。过滤点在登录/人员列表/物料/MOM 出库单四处,
全部服务端钉死,客户端传什么都放不大。
★ 物料必须用**前缀** LIKE,不能反推成 ILIKE '%IRIS%':MOM 里 LICA 的物料是
`LICA/<中文>`,而本部门分类树里另有 `IRIS/成品/LICA/…`(本就属于本部门),
前缀匹配天然区分得开。
- MOM 出库单只读查询(直连 MOM 库):不走 MOM 现成的 /outbound 接口 ——
那个要 JWT + permission_required,且对非特权账号按 consumer_name 做行级
隔离,服务账号只能拿到自己名下的单。分页必须两段式(先按单号 GROUP BY
分页,再 IN 捞明细),对宽表直接分页会得到明细行数而不是单据数。
- 出料功能:产品 ↔ 出库单存档(product_outbounds)与任务 ↔ 出库明细
(task_outbound_materials),供「这台设备对应 MOM 哪张单」的展示。
⚠️ 快照一律由后端拿 ID 去 MOM 现查,不接受前端传入,否则前端可伪造单据。
This commit is contained in:
232
frontend/src/components/admin/CreateTaskDialog.tsx
Normal file
232
frontend/src/components/admin/CreateTaskDialog.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 创建任务弹窗 —— 产品已由调用方(任务列表的某一行)确定,这里选工序/接收人/备注,
|
||||
* 并可从 MOM 出库单里勾选出库物料一并挂上。
|
||||
*
|
||||
* ⚠️ 表单重置的 useEffect 必须写在 `if (!product) return null` **之前** ——
|
||||
* 常驻挂载的 memo 组件先条件返回再调 Hook 会违反 Rules of Hooks,产品从
|
||||
* null 变非 null 时 Hook 数量错位直接崩(TaskTreeViewer.tsx 有同样的血泪注释)。
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Package, Truck } from "lucide-react";
|
||||
|
||||
import { Modal } from "../TaskTree/TaskTreeViewer";
|
||||
import { useToast } from "../ui/Toast";
|
||||
import MomOutboundPicker from "./MomOutboundPicker";
|
||||
import { createTask } from "../../services/taskApi";
|
||||
import { listUsers, type UserOption } from "../../services/userApi";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import { taskOptionsFor } from "../../constants/task";
|
||||
import type { MomOutboundOrder } from "../../types/api";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 目标产品 —— 由任务列表里点的那一行确定 */
|
||||
product: ProductResponse | null;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const INPUT_CLS =
|
||||
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 disabled:bg-gray-100 disabled:text-gray-400";
|
||||
|
||||
export default function CreateTaskDialog({ open, onClose, product, onCreated }: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [taskName, setTaskName] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState("");
|
||||
const [remark, setRemark] = useState("");
|
||||
const [pickedOrders, setPickedOrders] = useState<MomOutboundOrder[]>([]);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ⚠️ 必须在 `if (!product) return null` 之前
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTaskName("");
|
||||
setAssigneeId("");
|
||||
setRemark("");
|
||||
setPickedOrders([]);
|
||||
setPickerOpen(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
listUsers()
|
||||
.then(setUsers)
|
||||
.catch(() => toast("加载人员列表失败", "error"));
|
||||
}, [open, toast]);
|
||||
|
||||
// 可选工序随产品的生命周期阶段/宏观状态变化
|
||||
const stepOptions = useMemo(
|
||||
() => taskOptionsFor(product?.lifecycle_phase, true, product?.overall_status),
|
||||
[product?.lifecycle_phase, product?.overall_status],
|
||||
);
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const pickedLineCount = pickedOrders.reduce((s, o) => s + o.lines.length, 0);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!product) return;
|
||||
if (!taskName) {
|
||||
toast("请选择工序", "error");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTask({
|
||||
product_id: product.id,
|
||||
task_name: taskName,
|
||||
assignee_id: assigneeId || null,
|
||||
remark: remark.trim() || undefined,
|
||||
mom_line_ids: pickedOrders.flatMap((o) => o.lines.map((l) => l.line_id)),
|
||||
});
|
||||
toast("任务已创建", "success");
|
||||
onClose();
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "创建任务失败"), "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="创建任务"
|
||||
widthClass="max-w-lg"
|
||||
bodyClassName="max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
{/* ---- 产品信息(只读) ---- */}
|
||||
<div className="mb-4 rounded-lg bg-blue-50 px-3 py-2.5 text-sm text-blue-700">
|
||||
<p className="flex items-center gap-1.5 font-medium">
|
||||
<Package className="h-4 w-4" />
|
||||
<span className="font-mono">{product.serial_number}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-blue-500">
|
||||
{product.material_name || "—"}
|
||||
{product.overall_status ? ` · 当前状态 ${product.overall_status}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ---- 表单 ---- */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">
|
||||
工序 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select value={taskName} onChange={(e) => setTaskName(e.target.value)} className={INPUT_CLS}>
|
||||
<option value="">请选择工序</option>
|
||||
{stepOptions.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">接收人</label>
|
||||
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className={INPUT_CLS}>
|
||||
<option value="">暂不指派(留在仓库)</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.username} value={u.username}>
|
||||
{u.full_name}({u.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">备注</label>
|
||||
<textarea
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
placeholder="初始描述 / 交接备注"
|
||||
className={`${INPUT_CLS} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ---- 出库物料 ---- */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">
|
||||
出库物料 <span className="text-gray-400">(可选,之后也能追加)</span>
|
||||
</label>
|
||||
{pickedOrders.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-gray-300 px-3 py-2.5 text-sm text-gray-500 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||
>
|
||||
<Truck className="h-4 w-4" />
|
||||
从 MOM 出库单选择物料
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{pickedOrders.map((o) => (
|
||||
<div key={o.outbound_no}
|
||||
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs">
|
||||
<span className="font-mono font-medium text-gray-800">{o.outbound_no}</span>
|
||||
<span className="text-gray-500">{o.line_count} 条物料</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickedOrders((prev) => prev.filter((x) => x.outbound_no !== o.outbound_no))}
|
||||
className="ml-auto rounded px-1.5 text-gray-400 hover:bg-white hover:text-red-500"
|
||||
title="移除"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ 继续添加出库单
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- 底部操作 ---- */}
|
||||
<div className="mt-5 flex items-center justify-between border-t border-gray-100 pt-3">
|
||||
<span className="text-xs text-gray-400">
|
||||
{pickedLineCount > 0 ? `将挂载 ${pickedLineCount} 条物料` : ""}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} disabled={submitting}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting || !taskName}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
创建任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 选择器叠在创建弹窗之上 */}
|
||||
<MomOutboundPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={(_ids, orders) => {
|
||||
setPickedOrders((prev) => {
|
||||
const seen = new Set(prev.map((o) => o.outbound_no));
|
||||
return [...prev, ...orders.filter((o) => !seen.has(o.outbound_no))];
|
||||
});
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
existingOrderNos={pickedOrders.map((o) => o.outbound_no)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
412
frontend/src/components/admin/MomOutboundPicker.tsx
Normal file
412
frontend/src/components/admin/MomOutboundPicker.tsx
Normal file
@ -0,0 +1,412 @@
|
||||
/**
|
||||
* MOM 出库单选择器 — 从 MOM 搜索出库单,**按整张单**勾选
|
||||
*
|
||||
* 粒度说明:勾选的是单据(outbound_no),确认时把该单**全部明细行 ID** 一起提交
|
||||
* (后端按明细行落快照)。业务上确认过「单据里的物料都是相关的」,不存在无关物料。
|
||||
*
|
||||
* 过滤维度(都是**收窄**,可见范围由后端强制,前端传什么都放不大):
|
||||
* · 关键词 —— 出库单号 / 物料名称 / 规格型号 / SKU / 领用人
|
||||
* · 出库时间区间
|
||||
* · 领用人 —— **打开时默认筛成当前账号本人**(理由见 open 时的那个 effect)
|
||||
*
|
||||
* 已经挂过的单据会显示「已挂载」并禁止再选 —— 后端虽然有唯一约束兜底,但让用户
|
||||
* 在这里就能看出来更省事。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DatePicker, Modal, Select } from "antd";
|
||||
import { ChevronDown, ChevronRight, Loader2, Package, Search } from "lucide-react";
|
||||
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useToast } from "../ui/Toast";
|
||||
import {
|
||||
listMomOutboundConsumers,
|
||||
searchMomOutbounds,
|
||||
} from "../../services/momApi";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import type { MomOutboundOrder } from "../../types/api";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 确认选择:回传选中的全部明细行 ID 与选中的单据(供调用方展示) */
|
||||
onConfirm: (momLineIds: number[], orders: MomOutboundOrder[]) => void;
|
||||
submitting?: boolean;
|
||||
/** 该任务/产品已挂载的出库单号 —— 这些单在列表里标记「已挂载」且不可再选 */
|
||||
existingOrderNos?: string[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** 把当前筛选条件拼成一个字符串,用作「筛选是否变了」的比较键 */
|
||||
function makeFilterKey(
|
||||
kw: string, consumer?: string, start?: string, end?: string,
|
||||
): string {
|
||||
return [kw.trim(), consumer ?? "", start ?? "", end ?? ""].join("|");
|
||||
}
|
||||
const EMPTY_FILTER_KEY = makeFilterKey("");
|
||||
|
||||
/** 出库时间 → 本地可读格式 */
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default function MomOutboundPicker({
|
||||
open, onClose, onConfirm, submitting = false, existingOrderNos = [],
|
||||
}: Props) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
/** 当前登录账号的姓名 —— 打开时默认拿它去筛领用人(未登录兜底为空串 = 不筛) */
|
||||
const accountName = (user?.display_name ?? "").trim();
|
||||
|
||||
// ---- 筛选条件 ----
|
||||
const [keyword, setKeyword] = useState("");
|
||||
// 只留 YYYY-MM-DD 字符串,RangePicker 走非受控 + key 重挂载来重置,
|
||||
// 这样不必引入 dayjs 的类型(它不是本项目的直接依赖)
|
||||
const [range, setRange] = useState<[string, string] | null>(null);
|
||||
const [pickerKey, setPickerKey] = useState(0);
|
||||
const [consumer, setConsumer] = useState<string | undefined>();
|
||||
|
||||
// ---- 下拉数据 ----
|
||||
const [consumerOptions, setConsumerOptions] = useState<string[]>([]);
|
||||
|
||||
// ---- 结果 ----
|
||||
const [orders, setOrders] = useState<MomOutboundOrder[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const existing = new Set(existingOrderNos);
|
||||
const lastKeyRef = useRef<string>(EMPTY_FILTER_KEY);
|
||||
const [start, end] = range ?? ["", ""];
|
||||
|
||||
const load = useCallback(async (
|
||||
kw: string, c?: string, sd?: string, ed?: string,
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await searchMomOutbounds({
|
||||
keyword: kw.trim() || undefined,
|
||||
consumer: c || undefined,
|
||||
start_date: sd || undefined,
|
||||
end_date: ed || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
setOrders(res.orders);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "加载 MOM 出库单失败"), "error");
|
||||
setOrders([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// ---- 打开时重置 + 拉一次可选领用人,再**带着默认领用人**查一次 ----
|
||||
//
|
||||
// 为什么两件事合在一个 effect 里:默认领用人就是当前账号本人,而「本人在不在
|
||||
// 可选列表里」得先拿到列表才知道(没领过料的人本来就不该被筛 —— 筛了会得到
|
||||
// 一屏空白,用户还以为系统坏了)。拆成两个 effect 的话只能先空筛查一次、拿到
|
||||
// 名单再改条件查第二次:既多打一次请求,还会先闪一屏别人的单据再被抽走。
|
||||
//
|
||||
// 拿不到名单(接口挂了)就退回「全部领用人」,不能卡住整个选择器。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyword("");
|
||||
setRange(null);
|
||||
setPickerKey((k) => k + 1);
|
||||
setExpanded(new Set());
|
||||
setSelected(new Set());
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = await listMomOutboundConsumers();
|
||||
} catch {
|
||||
names = [];
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
setConsumerOptions(names);
|
||||
// MOM 的 consumer_name 存的就是人名,与登录态的 display_name 同一口径
|
||||
// (sys_user.username 的「姓名/账号」前半段)。
|
||||
const mine = accountName && names.includes(accountName) ? accountName : undefined;
|
||||
setConsumer(mine);
|
||||
// 先写 lastKeyRef 再 setState:防抖 effect 里靠它去重,不写就会在 300ms 后
|
||||
// 用同一组条件再查一次。
|
||||
lastKeyRef.current = makeFilterKey("", mine);
|
||||
load("", mine);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, load, accountName]);
|
||||
|
||||
// ---- 筛选变化 → 防抖搜索(300ms)。靠 filterKey 去重,避免重置时多打一次 ----
|
||||
const filterKey = makeFilterKey(keyword, consumer, start, end);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (filterKey === lastKeyRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
lastKeyRef.current = filterKey;
|
||||
load(keyword, consumer, start, end);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [filterKey, open, load, keyword, consumer, start, end]);
|
||||
|
||||
function toggleOrder(no: string) {
|
||||
if (existing.has(no)) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(no)) next.delete(no); else next.add(no);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpand(no: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(no)) next.delete(no); else next.add(no);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 勾选里有没有**不是自己领的**单。
|
||||
*
|
||||
* 判据:出库单的领用人(MOM 侧自由填写的姓名)≠ 当前登录人姓名。
|
||||
* ⚠️ 这是**提示**不是权限 —— 料的归属是设备不是人,代挂是合理操作
|
||||
* (测试替生产补挂、库管代录都会发生)。拦一道只是防手滑勾错别人的单。
|
||||
*/
|
||||
function proxyOrders(picked: MomOutboundOrder[]) {
|
||||
const myName = (user?.display_name ?? "").trim();
|
||||
if (!myName) return [];
|
||||
return picked.filter((o) => o.consumer_name && o.consumer_name !== myName);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
const picked = orders.filter((o) => selected.has(o.outbound_no));
|
||||
const proxy = proxyOrders(picked);
|
||||
const submit = () =>
|
||||
onConfirm(picked.flatMap((o) => o.lines.map((l) => l.line_id)), picked);
|
||||
|
||||
if (proxy.length === 0) return submit();
|
||||
// 提交前拦一道,用户还能取消回去改勾选
|
||||
const who = [...new Set(proxy.map((o) => o.consumer_name).filter(Boolean))].join("、");
|
||||
Modal.confirm({
|
||||
title: "确认代挂",
|
||||
content: `选中里有 ${proxy.length} 张不是你自己领的单(领用人:${who})。`
|
||||
+ "挂上去会记在你名下(挂载人),确认继续?",
|
||||
okText: "确认代挂",
|
||||
cancelText: "再看看",
|
||||
onOk: submit,
|
||||
});
|
||||
}
|
||||
|
||||
const selectedLineCount = orders
|
||||
.filter((o) => selected.has(o.outbound_no))
|
||||
.reduce((sum, o) => sum + o.lines.length, 0);
|
||||
|
||||
const hasFilter = !!(keyword.trim() || consumer || range);
|
||||
|
||||
return (
|
||||
// ⚠️ 这里用 antd 的 Modal,**不是**任务域那套自写 Modal。
|
||||
// 本选择器既被自写 Modal 打开(创建任务),也被 antd Modal 打开(创建产品)。
|
||||
// antd 弹窗走 portal 且默认 z-index 1000,自写 Modal 的 z-50 会被它整个
|
||||
// 盖住 —— 只从下面露出一截列表,看起来像「内容被后置」。
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="选择 MOM 出库物料"
|
||||
width={920}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
{/* ★ 只有中间的**单据列表**滚动,搜索筛选与底部操作条固定。
|
||||
整块一起滚的话,滚到下面就看不到「共 N 张单据」、也够不着「确认选择」——
|
||||
用户会以为没得选(实测反馈就是这么来的)。
|
||||
用 flex 列布局 + min-h-0 让中间那块真正可滚(min-h-0 不能省:
|
||||
flex 子项默认 min-height:auto,会撑开容器导致整页滚动)。 */}
|
||||
<div className="flex max-h-[72vh] flex-col">
|
||||
{/* ---- 搜索 ---- */}
|
||||
<div className="relative mb-2 shrink-0">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索出库单号 / 物料名称 / 规格型号 / SKU / 领用人"
|
||||
className="w-full rounded-lg border border-gray-200 py-2 pl-9 pr-8 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
{loading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- 筛选行 ---- */}
|
||||
<div className="mb-3 flex shrink-0 flex-wrap items-center gap-2">
|
||||
<RangePicker
|
||||
key={pickerKey}
|
||||
size="small"
|
||||
onChange={(_: unknown, strings: [string, string]) => {
|
||||
const [s, e] = strings;
|
||||
setRange(s && e ? [s, e] : null);
|
||||
}}
|
||||
placeholder={["开始日期", "结束日期"]}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="全部领用人"
|
||||
style={{ minWidth: 130 }}
|
||||
value={consumer}
|
||||
onChange={setConsumer}
|
||||
options={consumerOptions.map((n) => ({ value: n, label: n }))}
|
||||
/>
|
||||
{hasFilter && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setKeyword(""); setRange(null); setPickerKey((k) => k + 1);
|
||||
setConsumer(undefined);
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
清空筛选
|
||||
</button>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-gray-400">
|
||||
共 <span className="font-semibold text-gray-600">{total}</span> 张单据
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ---- 单据列表(**只有这块滚动**)---- */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{!loading && orders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-14 text-gray-400">
|
||||
<Package className="mb-2 h-10 w-10" />
|
||||
<p className="text-sm">{hasFilter ? "没有匹配的出库单" : "没有可选的出库单"}</p>
|
||||
{hasFilter && (
|
||||
<p className="mt-1 text-xs text-gray-300">试试放宽日期或清空筛选条件</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{orders.map((o) => {
|
||||
const isExisting = existing.has(o.outbound_no);
|
||||
const isSelected = selected.has(o.outbound_no);
|
||||
const isOpen = expanded.has(o.outbound_no);
|
||||
// 整行可点 = 勾选/取消(展开按钮自己 stopPropagation)。
|
||||
// 复选框只是状态的「显示」,不再是唯一入口 —— 所以它
|
||||
// readOnly + pointer-events-none,点它的事件穿透到整行上,
|
||||
// 避免 onChange 与整行 onClick 各切一次、等于没切。
|
||||
return (
|
||||
<div
|
||||
key={o.outbound_no}
|
||||
onClick={() => toggleOrder(o.outbound_no)}
|
||||
className={`rounded-lg border px-2.5 py-1.5 transition-colors ${
|
||||
isExisting ? "cursor-not-allowed border-gray-200 bg-gray-50"
|
||||
: isSelected ? "cursor-pointer border-blue-300 bg-blue-50"
|
||||
: "cursor-pointer border-gray-100 hover:border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{/* 单行放完:勾选 / 单号 / 类型 / 领用+经办+条数 / 时间 / 展开 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
disabled={isExisting}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className={`shrink-0 font-mono text-[13px] font-medium ${isExisting ? "text-gray-400" : "text-gray-800"}`}>
|
||||
{o.outbound_no}
|
||||
</span>
|
||||
{isExisting && (
|
||||
<span className="shrink-0 rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-bold text-gray-600">
|
||||
已挂载
|
||||
</span>
|
||||
)}
|
||||
{o.outbound_type && (
|
||||
<span className="shrink-0 rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
|
||||
{/* 中文名由后端按 MOM 码表下发;万一没下发就退回原始码,
|
||||
免得整块徽标凭空消失 */}
|
||||
{o.outbound_type_label || o.outbound_type}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate text-xs text-gray-500">
|
||||
{o.consumer_name && <>领用 {o.consumer_name}</>}
|
||||
{o.operator_name && <span className="ml-2 text-gray-400">经办 {o.operator_name}</span>}
|
||||
<span className="ml-2 text-gray-400">
|
||||
<span className="font-medium text-gray-600">{o.line_count}</span> 条物料
|
||||
{o.total_quantity != null && <> · 合计 {o.total_quantity}</>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-gray-400">
|
||||
{formatTime(o.outbound_time)}
|
||||
</span>
|
||||
{/* 看物料明细的按钮 —— 带边框才看得出是个按钮,别只给个裸图标 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(o.outbound_no);
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-0.5 rounded-md border border-gray-200 p-1 text-gray-500 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
title={isOpen ? "收起物料明细" : `查看物料明细(${o.line_count} 条)`}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
{isOpen ? <ChevronDown className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-1.5 space-y-0.5 border-t border-gray-100 pt-1.5 pl-6">
|
||||
{o.lines.map((l) => (
|
||||
<div key={l.line_id} className="flex flex-wrap items-baseline gap-x-3 text-xs">
|
||||
<span className="font-medium text-gray-700">{l.material_name || "(未命名物料)"}</span>
|
||||
{l.spec_model && <span className="text-gray-400">{l.spec_model}</span>}
|
||||
<span className="text-gray-500">× {l.quantity}</span>
|
||||
{l.warehouse_location && <span className="text-gray-400">库位 {l.warehouse_location}</span>}
|
||||
{l.request_no && <span className="text-gray-400">申请单 {l.request_no}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- 底部操作(固定,不随列表滚动)---- */}
|
||||
<div className="mt-3 flex shrink-0 items-center justify-between border-t border-gray-100 pt-3">
|
||||
<span className="text-xs text-gray-500">
|
||||
已选 <span className="font-semibold text-gray-700">{selected.size}</span> 张单
|
||||
(<span className="font-semibold text-gray-700">{selectedLineCount}</span> 条物料)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} disabled={submitting}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleConfirm} disabled={submitting || selected.size === 0}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { Loader2, AlertCircle } from "lucide-react";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import ProductCard from "./ProductCard";
|
||||
import TaskListCard from "./TaskListCard";
|
||||
import OutboundRecordsCard from "./OutboundRecordsCard";
|
||||
|
||||
interface QueryResultProps {
|
||||
loading: boolean;
|
||||
@ -33,6 +34,9 @@ export default function QueryResult({ loading, error, product }: QueryResultProp
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ProductCard product={product} />
|
||||
{/* 出库单据 —— 卡片按 productId 自取数据(与「产品管理 → 编辑产品」共用
|
||||
同一个组件)。无记录时显示空态 + 「追加出库单」入口。 */}
|
||||
<OutboundRecordsCard productId={product.id} />
|
||||
<TaskListCard tasks={product.task_tree || product.top_level_tasks} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Search, Loader2, Package, ChevronDown, ChevronRight,
|
||||
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
|
||||
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns, Plus,
|
||||
} from "lucide-react";
|
||||
import { Tooltip, Popover, Checkbox, Input, Button } from "antd";
|
||||
import api from "../../services/api";
|
||||
@ -14,6 +14,7 @@ import {
|
||||
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
|
||||
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import CreateTaskDialog from "../../components/admin/CreateTaskDialog";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
@ -92,6 +93,9 @@ export default function AdminTasksPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
|
||||
|
||||
// 🔧 创建任务弹窗 —— 产品由所点的那一行确定,弹窗里再选工序/接收人/出库物料
|
||||
const [createTarget, setCreateTarget] = useState<ProductResponse | null>(null);
|
||||
|
||||
// ---- 列配置(10列)----
|
||||
const columns: ColumnDef[] = [
|
||||
{
|
||||
@ -207,10 +211,17 @@ export default function AdminTasksPage() {
|
||||
render: (p) => {
|
||||
const isTreeLoading = treeLoading[p.serial_number];
|
||||
return (
|
||||
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
|
||||
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
|
||||
流转树
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
|
||||
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
|
||||
流转树
|
||||
</button>
|
||||
{/* 创建任务:产品由本行确定,弹窗里再选工序/接收人/出库物料 */}
|
||||
<button onClick={() => setCreateTarget(p)} className="flex items-center gap-1 rounded border border-emerald-200 px-2.5 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-50 transition-colors">
|
||||
<Plus className="h-3 w-3" />
|
||||
创建任务
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@ -924,6 +935,14 @@ export default function AdminTasksPage() {
|
||||
onClose={() => setModalTarget(null)}
|
||||
onSubmit={handleTransfer}
|
||||
/>
|
||||
|
||||
{/* 🔧 创建任务弹窗(含从 MOM 出库单选物料) */}
|
||||
<CreateTaskDialog
|
||||
open={createTarget !== null}
|
||||
product={createTarget}
|
||||
onClose={() => setCreateTarget(null)}
|
||||
onCreated={() => { loadProducts(keyword); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd";
|
||||
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import { SearchOutlined, PlusOutlined, MinusOutlined, TruckOutlined } from "@ant-design/icons";
|
||||
import api from "../../services/api";
|
||||
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
|
||||
import MomOutboundPicker from "../../components/admin/MomOutboundPicker";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import type { MomOutboundOrder } from "../../types/api";
|
||||
|
||||
// ============================================================
|
||||
// 类型
|
||||
@ -51,6 +53,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [createdSn, setCreatedSn] = useState<string | null>(null);
|
||||
|
||||
// ---- 建档时挂钩的 MOM 出库单(可选) ----
|
||||
const [pickedOrders, setPickedOrders] = useState<MomOutboundOrder[]>([]);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// ---- 初始化 ----
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@ -62,6 +68,8 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
setCreatedSn(null);
|
||||
groupCache.current.clear();
|
||||
groupLoadingMap.current.clear();
|
||||
setPickedOrders([]);
|
||||
setPickerOpen(false);
|
||||
loadSummary();
|
||||
}
|
||||
}, [open]);
|
||||
@ -182,6 +190,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
material_type: selected.material_type,
|
||||
external_serial: externalSerial.trim() || null,
|
||||
order_no: orderNo.trim() || null,
|
||||
// 挂钩的出库单:用户是按**整张单**勾选的,所以提交该单全部明细行 ID,
|
||||
// 后端归并回单据后写进 product_outbounds(source=manual)。
|
||||
// 不选就是空数组,后端不挂载。
|
||||
mom_line_ids: pickedOrders.flatMap((o) => o.lines.map((l) => l.line_id)),
|
||||
});
|
||||
setCreatedSn(data.serial_number);
|
||||
onCreated();
|
||||
@ -332,6 +344,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
// ============================================================
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="创建产品"
|
||||
open={open}
|
||||
@ -493,6 +506,43 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MOM 出库单挂钩(选填) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
出库单挂钩 <span className="text-xs text-gray-400">(选填,建档后也能补挂)</span>
|
||||
</label>
|
||||
{pickedOrders.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-gray-300 px-3 py-2.5 text-sm text-gray-500 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||
>
|
||||
<TruckOutlined />
|
||||
从 MOM 出库单选择
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{pickedOrders.map((o) => (
|
||||
<div key={o.outbound_no}
|
||||
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs">
|
||||
<span className="font-mono font-medium text-gray-800">{o.outbound_no}</span>
|
||||
<span className="text-gray-500">{o.line_count} 条物料</span>
|
||||
<Button
|
||||
type="text" size="small" danger
|
||||
className="ml-auto"
|
||||
onClick={() => setPickedOrders((prev) => prev.filter((x) => x.outbound_no !== o.outbound_no))}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="link" size="small" onClick={() => setPickerOpen(true)}>
|
||||
+ 继续添加出库单
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交 */}
|
||||
<Button
|
||||
type="primary"
|
||||
@ -507,5 +557,20 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* MOM 出库单选择器 —— 叠在创建产品弹窗之上 */}
|
||||
<MomOutboundPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={(_ids, orders) => {
|
||||
setPickedOrders((prev) => {
|
||||
const seen = new Set(prev.map((o) => o.outbound_no));
|
||||
return [...prev, ...orders.filter((o) => !seen.has(o.outbound_no))];
|
||||
});
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
existingOrderNos={pickedOrders.map((o) => o.outbound_no)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
48
frontend/src/services/momApi.ts
Normal file
48
frontend/src/services/momApi.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/** MOM 出库单相关接口 —— 任务挂载出库物料时搜索选择用 */
|
||||
import api from "./api";
|
||||
import type { MomOutboundSearchResponse } from "../types/api";
|
||||
|
||||
export interface MomOutboundSearchParams {
|
||||
/** 出库单号 / 物料名称 / 规格型号 / SKU / 领用人,任一命中 */
|
||||
keyword?: string;
|
||||
/** YYYY-MM-DD,含当日 */
|
||||
start_date?: string;
|
||||
/** YYYY-MM-DD,含当日 */
|
||||
end_date?: string;
|
||||
/** 按领用人(中文名)过滤 */
|
||||
consumer?: string;
|
||||
/** 跳过**单据数**(不是明细行数) */
|
||||
skip?: number;
|
||||
/** 返回**单据数**,后端上限 100 */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 MOM 出库单(按单据分页,带回每张单的明细)。
|
||||
* 对应后端 GET /api/v1/mom-outbounds
|
||||
*
|
||||
* ⚠️ 可见范围(公司隔离 + 跨部门例外)由后端服务层钉死,本接口**没有任何**
|
||||
* 能放大范围的参数 —— 界面筛选只能收窄。
|
||||
*/
|
||||
export async function searchMomOutbounds(
|
||||
params: MomOutboundSearchParams = {},
|
||||
): Promise<MomOutboundSearchResponse> {
|
||||
const { data } = await api.get<MomOutboundSearchResponse>("/mom-outbounds", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本部门出库单里出现过的**领用人姓名**(去重,按出现次数降序)。
|
||||
* 对应后端 GET /api/v1/mom-outbounds/consumers
|
||||
*
|
||||
* ⚠️ 后端**已按权限范围过滤** —— 下拉里不会出现用户看不到的人名。
|
||||
*/
|
||||
export async function listMomOutboundConsumers(): Promise<string[]> {
|
||||
const { data } = await api.get<string[]>("/mom-outbounds/consumers");
|
||||
return data;
|
||||
}
|
||||
|
||||
// 注:原先这里有 addTaskOutboundMaterials / removeTaskOutboundMaterial
|
||||
// 两个**任务级**的读写函数。物料已统一为**设备级**,任务级的三个端点连同
|
||||
// 实现一起删除 —— 挂载/查看/删除/报废一律走 productApi 里的
|
||||
// mountProductOutboundMaterials / removeProductOutboundMaterial。
|
||||
@ -26,6 +26,19 @@ export interface TaskCompletePayload {
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface TaskCreatePayload {
|
||||
product_id: string;
|
||||
task_name: string;
|
||||
assignee_id?: string | null;
|
||||
remark?: string;
|
||||
/**
|
||||
* 创建时一并挂载的 MOM 出库**明细行** ID(trans_outbound.id)。
|
||||
* 前端按整张出库单勾选,提交时把该单全部明细 ID 带过来。
|
||||
* 不传 = 不挂载。
|
||||
*/
|
||||
mom_line_ids?: number[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 方法
|
||||
// ============================================================
|
||||
@ -43,6 +56,16 @@ export async function listTasks(productId?: string): Promise<TaskListResponse> {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务 — 对应后端 POST /api/v1/tasks/
|
||||
* payload.mom_line_ids 非空时,后端会在**同一事务**里把对应的 MOM 出库明细
|
||||
* 挂到新任务上,不存在「任务建好了但物料没挂上」的中间态。
|
||||
*/
|
||||
export async function createTask(payload: TaskCreatePayload): Promise<TaskResponse> {
|
||||
const { data } = await api.post<TaskResponse>("/tasks/", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 创建子任务 */
|
||||
export async function createSubtask(
|
||||
parentTaskId: string,
|
||||
|
||||
21
frontend/src/services/userApi.ts
Normal file
21
frontend/src/services/userApi.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/** 用户列表 —— 对接 MOM sys_user,只返回本部门人员 */
|
||||
import api from "./api";
|
||||
|
||||
export interface UserOption {
|
||||
id: string;
|
||||
/** 登录账号,即任务里的 assignee_id 口径 */
|
||||
username: string;
|
||||
/** 中文姓名 */
|
||||
full_name: string;
|
||||
department: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本部门人员列表。
|
||||
* 对应后端 GET /api/v1/users/ —— 部门隔离由服务端按 ORG_DEPARTMENT 钉死,
|
||||
* 客户端传什么都没用。
|
||||
*/
|
||||
export async function listUsers(keyword = "", limit = 200): Promise<UserOption[]> {
|
||||
const { data } = await api.get<UserOption[]>("/users/", { params: { keyword, limit } });
|
||||
return data;
|
||||
}
|
||||
Reference in New Issue
Block a user