Просмотр исходного кода

feat(s0): 物料职责维护表单改选择控件+职责枚举options接口

将 EmpWorkDutyList 弹窗五类字段由纯文本改为可选择控件(雇员/生产线/物料起止=远程搜索,库位=下拉,职责=多选),复用现有 employees/locations/production-lines/materials 接口;新增只读 GET emp-work-duties/duties/options 复用后端 DutyMap 单一枚举源。存储口径不变:职责仍逗号串(加载 split/提交 join,Up-Shelf 大小写原样)。

bump: Web 2.4.289 / server 1.0.337
YY968XX 1 неделя назад
Родитель
Сommit
1d931d71b7

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.288",
+	"version": "2.4.289",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 8 - 0
Web/src/views/aidop/s0/api/s0WarehouseApi.ts

@@ -551,6 +551,12 @@ export interface S0EmpWorkDutyUpsert {
 	updateUser?: string;
 }
 
+// 职责下拉项(后端 DutyMap:value=原始码 / label=中文,单一枚举源,前端不再重复维护)
+export interface S0DutyOption {
+	value: string;
+	label: string;
+}
+
 export const s0EmpWorkDutiesApi = {
 	list: (params: Record<string, unknown>) =>
 		service.get<Paged<S0EmpWorkDutyRow>>('/api/s0/warehouse/emp-work-duties', { params }).then(unwrap),
@@ -560,6 +566,8 @@ export const s0EmpWorkDutiesApi = {
 	update: (id: number, body: S0EmpWorkDutyUpsert) =>
 		service.put<S0EmpWorkDutyRow>(`/api/s0/warehouse/emp-work-duties/${id}`, body).then(unwrap),
 	delete: (id: number) => service.delete(`/api/s0/warehouse/emp-work-duties/${id}`).then(unwrap),
+	dutyOptions: () =>
+		service.get<S0DutyOption[]>('/api/s0/warehouse/emp-work-duties/duties/options').then(unwrap),
 };
 
 // ========== 物料状态任务指派 ==========

+ 183 - 12
Web/src/views/aidop/s0/warehouse/EmpWorkDutyList.vue

@@ -75,14 +75,62 @@
 							</el-radio-group>
 						</el-form-item>
 					</el-col>
-					<el-col :span="12"><el-form-item label="员工编码" prop="employee"><el-input v-model="form.employee" /></el-form-item></el-col>
-					<el-col :span="12"><el-form-item label="库位"><el-input v-model="form.location" /></el-form-item></el-col>
-					<el-col v-if="scopeMode === 'single'" :span="12"><el-form-item label="单物料编码"><el-input v-model="form.itemNum" /></el-form-item></el-col>
-					<el-col v-if="scopeMode === 'range'" :span="12"><el-form-item label="物料编码起"><el-input v-model="form.itemNum1" /></el-form-item></el-col>
-					<el-col v-if="scopeMode === 'range'" :span="12"><el-form-item label="物料编码止"><el-input v-model="form.itemNum2" /></el-form-item></el-col>
-					<el-col :span="12"><el-form-item label="生产线"><el-input v-model="form.prodLine" /></el-form-item></el-col>
+					<el-col :span="12">
+						<el-form-item label="员工编码" prop="employee">
+							<el-select v-model="form.employee" filterable remote clearable :remote-method="remoteSearchEmployee"
+								:loading="employeeLoading" placeholder="输入编码/姓名搜索" style="width: 100%">
+								<el-option v-for="o in employeeOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="库位">
+							<el-select v-model="form.location" filterable clearable placeholder="请选择库位" style="width: 100%">
+								<el-option v-for="o in locationOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col v-if="scopeMode === 'single'" :span="12">
+						<el-form-item label="单物料编码">
+							<el-select v-model="form.itemNum" filterable remote clearable :remote-method="remoteSearchItemSingle"
+								:loading="materialLoading" placeholder="输入编码/名称搜索" style="width: 100%">
+								<el-option v-for="o in itemSingleOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col v-if="scopeMode === 'range'" :span="12">
+						<el-form-item label="物料编码起">
+							<el-select v-model="form.itemNum1" filterable remote clearable :remote-method="remoteSearchItemStart"
+								:loading="materialLoading" placeholder="输入编码/名称搜索" style="width: 100%">
+								<el-option v-for="o in itemStartOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col v-if="scopeMode === 'range'" :span="12">
+						<el-form-item label="物料编码止">
+							<el-select v-model="form.itemNum2" filterable remote clearable :remote-method="remoteSearchItemEnd"
+								:loading="materialLoading" placeholder="输入编码/名称搜索" style="width: 100%">
+								<el-option v-for="o in itemEndOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="生产线">
+							<el-select v-model="form.prodLine" filterable remote clearable :remote-method="remoteSearchProdLine"
+								:loading="prodLineLoading" placeholder="输入编码/名称搜索" style="width: 100%">
+								<el-option v-for="o in prodLineOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
 					<el-col :span="12"><el-form-item label="员工类型"><el-input v-model="form.empType" /></el-form-item></el-col>
-					<el-col :span="24"><el-form-item label="职责(原始值)"><el-input v-model="form.duty" placeholder="如 iss-po,rct-wo" /></el-form-item></el-col>
+					<el-col :span="24">
+						<el-form-item label="职责">
+							<el-select v-model="dutyList" multiple filterable clearable collapse-tags collapse-tags-tooltip
+								placeholder="请选择职责(可多选)" style="width: 100%">
+								<el-option v-for="o in dutyOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
 					<el-col v-if="false" :span="12"><el-form-item label="域编码"><el-input v-model="form.domainCode" /></el-form-item></el-col>
 					<el-col :span="12"><el-form-item label="自定义字段2"><el-input v-model="form.ufld2" /></el-form-item></el-col>
 					<el-col :span="24">
@@ -99,13 +147,27 @@
 </template>
 
 <script setup lang="ts" name="aidopS0WhEmpWorkDuty">
-import { computed, onMounted, reactive, ref, watch } from 'vue';
+import { computed, onMounted, reactive, ref, watch, type Ref } from 'vue';
 import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
-import { s0EmpWorkDutiesApi, type S0EmpWorkDutyRow, type S0EmpWorkDutyUpsert } from '../api/s0WarehouseApi';
+import {
+	s0EmpWorkDutiesApi,
+	s0EmployeesApi,
+	s0LocationsApi,
+	type S0DutyOption,
+	type S0EmpWorkDutyRow,
+	type S0EmpWorkDutyUpsert,
+} from '../api/s0WarehouseApi';
+import { s0MfgProductionLinesApi, searchMaterialsForMfg } from '../api/s0ManufacturingApi';
 import { loadOrgList, type OrgOption } from '../api/s0SalesApi';
 
+// 下拉/远程搜索选项通用形态:value=业务编码(实际保存值),label=展示文本
+interface Opt {
+	value: string;
+	label: string;
+}
+
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '物料职责维护');
 const query = reactive({ keyword: '', domainCode: '', employee: '', location: '', itemNum: '', page: 1, pageSize: 20 });
@@ -121,6 +183,93 @@ const scopeMode = ref<'single' | 'range'>('range');
 const emptyF = (): S0EmpWorkDutyUpsert => ({ companyRefId: undefined, factoryRefId: undefined, domainCode: '', employee: '', itemNum: '', itemNum1: '', itemNum2: '', location: '', prodLine: '', duty: '', empType: '', ufld2: '' });
 const form = reactive<S0EmpWorkDutyUpsert>(emptyF());
 
+// 下拉/远程搜索选项集合:雇员/生产线/物料为远程搜索,库位/职责为进入页面一次性加载
+const employeeOptions = ref<Opt[]>([]);
+const locationOptions = ref<Opt[]>([]);
+const prodLineOptions = ref<Opt[]>([]);
+const itemSingleOptions = ref<Opt[]>([]);
+const itemStartOptions = ref<Opt[]>([]);
+const itemEndOptions = ref<Opt[]>([]);
+const dutyOptions = ref<S0DutyOption[]>([]);
+// 职责多选(数组)↔ 存储口径(逗号分隔字符串 form.duty)在 openEdit/submitForm 处 split/join
+const dutyList = ref<string[]>([]);
+const employeeLoading = ref(false);
+const prodLineLoading = ref(false);
+const materialLoading = ref(false);
+
+function toOpt(value?: string | null, label?: string | null): Opt {
+	const v = (value ?? '').trim();
+	const l = (label ?? '').trim();
+	return { value: v, label: l || v };
+}
+// 编辑历史数据时,当前编码可能不在最新 options 中:注入一条以保证正确回显、不丢值
+function seedOption(target: Ref<Opt[]>, value?: string | null, label?: string | null) {
+	const v = (value ?? '').trim();
+	if (!v) return;
+	if (!target.value.some((o) => o.value === v)) {
+		target.value = [toOpt(v, label), ...target.value];
+	}
+}
+function splitDuty(raw?: string | null): string[] {
+	return (raw ?? '').split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean);
+}
+
+async function remoteSearchEmployee(keyword: string) {
+	if (!keyword) return;
+	employeeLoading.value = true;
+	try {
+		const data = await s0EmployeesApi.list({ page: 1, pageSize: 50, keyword });
+		employeeOptions.value = data.list.map((e) => toOpt(e.employee, `${e.employee} ${e.name ?? ''}`));
+	} catch {
+		/* 搜索失败保留已有选项,不清空 */
+	} finally {
+		employeeLoading.value = false;
+	}
+}
+async function remoteSearchProdLine(keyword: string) {
+	if (!keyword) return;
+	prodLineLoading.value = true;
+	try {
+		const data = await s0MfgProductionLinesApi.list({ page: 1, pageSize: 50, keyword });
+		prodLineOptions.value = data.list.map((l) => toOpt(l.line, `${l.line} ${l.describe ?? ''}`));
+	} catch {
+		/* 搜索失败保留已有选项,不清空 */
+	} finally {
+		prodLineLoading.value = false;
+	}
+}
+async function remoteSearchMaterial(keyword: string, target: Ref<Opt[]>) {
+	if (!keyword) return;
+	materialLoading.value = true;
+	try {
+		const data = await searchMaterialsForMfg({ keyword });
+		target.value = data.list.map((m) => toOpt(m.itemNum, `${m.itemNum} ${m.descr ?? ''}`));
+	} catch {
+		/* 搜索失败保留已有选项,不清空 */
+	} finally {
+		materialLoading.value = false;
+	}
+}
+const remoteSearchItemSingle = (kw: string) => remoteSearchMaterial(kw, itemSingleOptions);
+const remoteSearchItemStart = (kw: string) => remoteSearchMaterial(kw, itemStartOptions);
+const remoteSearchItemEnd = (kw: string) => remoteSearchMaterial(kw, itemEndOptions);
+
+async function loadLocationOptions() {
+	try {
+		const data = await s0LocationsApi.options({ limit: 500 });
+		locationOptions.value = data.map((o) => toOpt(o.value, o.label));
+	} catch {
+		locationOptions.value = [];
+	}
+}
+async function loadDutyOptions() {
+	try {
+		dutyOptions.value = await s0EmpWorkDutiesApi.dutyOptions();
+	} catch {
+		dutyOptions.value = [];
+	}
+}
+
 // 单甲方私有云口径:公司/工厂不在前端启用,统一注入全局默认组织(不依赖登录账号 tenant_id)
 const DEFAULT_COMPANY_REF_ID = '1329900200001';
 const DEFAULT_FACTORY_REF_ID = '1329900200002';
@@ -159,12 +308,33 @@ async function loadList() {
 	} catch { rows.value = []; total.value = 0; } finally { loading.value = false; }
 }
 function resetQuery() { Object.assign(query, { keyword: '', domainCode: '', employee: '', location: '', itemNum: '', page: 1 }); void loadList(); }
-function resetForm() { editingId.value = null; scopeMode.value = 'range'; Object.assign(form, emptyF()); formRef.value?.clearValidate(); }
+function resetForm() {
+	editingId.value = null;
+	scopeMode.value = 'range';
+	Object.assign(form, emptyF());
+	dutyList.value = [];
+	// 清空远程搜索选项(库位/职责为全局加载,保留)
+	employeeOptions.value = [];
+	prodLineOptions.value = [];
+	itemSingleOptions.value = [];
+	itemStartOptions.value = [];
+	itemEndOptions.value = [];
+	formRef.value?.clearValidate();
+}
 function openCreate() { resetForm(); dialogTitle.value = '新增物料职责'; dialogVisible.value = true; }
 function openEdit(row: S0EmpWorkDutyRow) {
 	resetForm(); editingId.value = row.id; dialogTitle.value = `编辑物料职责`;
 	scopeMode.value = row.itemNum ? 'single' : 'range';
 	Object.assign(form, { companyRefId: row.companyRefId, factoryRefId: row.factoryRefId, domainCode: row.domainCode ?? '', employee: row.employee, itemNum: row.itemNum ?? '', itemNum1: row.itemNum1 ?? '', itemNum2: row.itemNum2 ?? '', location: row.location ?? '', prodLine: row.prodLine ?? '', duty: row.duty ?? '', empType: row.empType ?? '', ufld2: row.ufld2 ?? '' });
+	// 职责字符串 → 数组(存储口径不变,仅前端展示为多选)
+	dutyList.value = splitDuty(row.duty);
+	// 回显:注入当前编码的选项,保证历史值不在最新 options 时仍显示、不丢值
+	seedOption(employeeOptions, row.employee, `${row.employee} ${row.employeeName ?? ''}`);
+	seedOption(locationOptions, row.location, row.locationName);
+	seedOption(prodLineOptions, row.prodLine, row.lineName);
+	seedOption(itemSingleOptions, row.itemNum, row.itemNum);
+	seedOption(itemStartOptions, row.itemNum1, row.itemNum1);
+	seedOption(itemEndOptions, row.itemNum2, row.itemNum2);
 	dialogVisible.value = true;
 }
 async function submitForm() {
@@ -172,7 +342,8 @@ async function submitForm() {
 	applyDefaultOrg();
 	saving.value = true;
 	try {
-		const payload: S0EmpWorkDutyUpsert = { ...form };
+		// 职责数组 → 逗号分隔字符串(存储口径不变;Up-Shelf 等大小写原样保留)
+		const payload: S0EmpWorkDutyUpsert = { ...form, duty: dutyList.value.join(',') };
 		if (scopeMode.value === 'single') {
 			if (!payload.itemNum?.trim()) { ElMessage.warning('请填写单物料编码'); return; }
 			payload.itemNum = payload.itemNum.trim();
@@ -199,7 +370,7 @@ async function loadOrgOptions() {
 	factoryOptions.value = factories.filter((item) => item.id && item.id !== '0');
 }
 
-onMounted(() => { void loadList(); void loadOrgOptions(); });
+onMounted(() => { void loadList(); void loadOrgOptions(); void loadLocationOptions(); void loadDutyOptions(); });
 </script>
 
 <style scoped lang="scss">

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.336</AssemblyVersion>
-    <FileVersion>1.0.336</FileVersion>
-    <Version>1.0.336</Version>
+    <AssemblyVersion>1.0.337</AssemblyVersion>
+    <FileVersion>1.0.337</FileVersion>
+    <Version>1.0.337</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 11 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0EmpWorkDutiesController.cs

@@ -153,6 +153,17 @@ public class AdoS0EmpWorkDutiesController : ControllerBase
         return Ok(new { message = "删除成功" });
     }
 
+    /// <summary>
+    /// 职责枚举下拉项(只读)。value=原始码(大小写原样,如 Up-Shelf)、label=中文。
+    /// 直接复用同类内 <see cref="DutyMap"/>,前后端不各维护一份枚举;为纯静态字典,不涉及租户。
+    /// </summary>
+    [HttpGet("duties/options")]
+    public IActionResult GetDutyOptions()
+    {
+        var options = DutyMap.Select(kv => new { value = kv.Key, label = kv.Value }).ToList();
+        return Ok(options);
+    }
+
     /// <summary>员工与库位必须属于当前租户(同 domain),杜绝为他租户员工分配职责/绑定他租户库位。</summary>
     private async Task<IActionResult?> ValidateRelationsInTenantAsync(long tenantId, AdoS0EmpWorkDutyUpsertDto dto)
     {