Quellcode durchsuchen

feat(web-admin): link system user on personnel form

Allow binding a login account on driver/escort personnel so
miniprogram driver-plan visibility can resolve by sys_user_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong vor 1 Monat
Ursprung
Commit
3dc71788d4

+ 4 - 0
web-admin/src/api/pl/index.ts

@@ -162,6 +162,8 @@ export interface Personnel {
   status: string
   remark?: string
   photoUrls?: string
+  /** 关联系统用户 ID */
+  sysUserId?: string | null
   nearestExpireDate?: string
   certList?: CertRead[]
   createTime: string
@@ -181,6 +183,8 @@ export interface PersonnelPayload {
   status: string
   remark?: string
   photoUrls?: string
+  /** 关联系统用户;传空串/null 表示清空 */
+  sysUserId?: string | null
   certList?: CertRow[]
 }
 

+ 60 - 3
web-admin/src/components/business/pl-personnel-form-dialog/index.vue

@@ -30,6 +30,24 @@
         <el-form-item label="手机号" prop="phoneNo">
           <el-input v-model="formData.phoneNo" placeholder="请输入手机号(选填)" maxlength="32" />
         </el-form-item>
+        <el-form-item label="关联用户账号" prop="sysUserId">
+          <el-select
+            v-model="formData.sysUserId"
+            placeholder="可选,绑定小程序登录账号"
+            filterable
+            clearable
+            :loading="userSelectLoading"
+            style="width: 100%"
+            @focus="ensureUserOptions"
+          >
+            <el-option
+              v-for="user in userOptions"
+              :key="user.userId"
+              :label="user.displayName"
+              :value="user.userId"
+            />
+          </el-select>
+        </el-form-item>
         <el-form-item label="年龄" prop="age">
           <el-input-number
             v-model="formData.age"
@@ -110,6 +128,7 @@
 
 <script setup lang="ts">
   import { Check, Close } from '@element-plus/icons-vue'
+  import { fetchUserOptions } from '@/api/admin'
   import { personnelApi, type CertRow, type PersonnelDetail } from '@/api/pl'
   import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
   import PlCertFormList from '@/components/business/pl-cert-form-list/index.vue'
@@ -185,9 +204,38 @@
     personnelAffiliation: props.affiliation,
     status: '1',
     remark: '',
-    photoUrls: ''
+    photoUrls: '',
+    sysUserId: '' as string
   })
 
+  const userSelectLoading = ref(false)
+  const userOptions = ref<Array<{ userId: string; nickName: string; displayName: string }>>([])
+
+  async function ensureUserOptions() {
+    if (userOptions.value.length || userSelectLoading.value) return
+    userSelectLoading.value = true
+    try {
+      const res = await fetchUserOptions()
+      const data = (res as any)?.data ?? res
+      userOptions.value = (Array.isArray(data) ? data : [])
+        .map((u: Record<string, unknown>) => {
+          const userId = String(u.value ?? u.id ?? u.userId ?? '')
+          const name = String(u.label ?? u.nickName ?? u.userName ?? userId)
+          const account = String(u.userName ?? u.account ?? '').trim()
+          return {
+            userId,
+            nickName: name,
+            displayName: account && account !== name ? `${name}(${account})` : name
+          }
+        })
+        .filter((u) => u.userId)
+    } catch {
+      userOptions.value = []
+    } finally {
+      userSelectLoading.value = false
+    }
+  }
+
   const requiredPersonnelCertNames = computed(() =>
     isCarrierPersonnel.value
       ? []
@@ -227,7 +275,8 @@
       personnelAffiliation: props.affiliation,
       status: '1',
       remark: '',
-      photoUrls: ''
+      photoUrls: '',
+      sysUserId: ''
     })
     resetCertList()
   }
@@ -286,7 +335,8 @@
         personnelAffiliation: props.affiliation,
         status: detail.status,
         remark: detail.remark || '',
-        photoUrls: detail.photoUrls || ''
+        photoUrls: detail.photoUrls || '',
+        sysUserId: detail.sysUserId || ''
       })
       certList.value = mergePersonnelCertSlots(
         (detail.certList || []).map((c) => ({
@@ -310,6 +360,7 @@
 
   async function openDialog() {
     resetForm()
+    void ensureUserOptions()
     if (props.personnelId) {
       await loadDetail(props.personnelId)
     } else if (props.initialPersonnelType) {
@@ -341,6 +392,7 @@
         status: formData.status,
         remark: formData.remark || undefined,
         photoUrls: formData.photoUrls ?? '',
+        sysUserId: (formData.sysUserId || '').trim() || null,
         certList: certList.value.map((c) => ({
           certId: c.certId,
           certName: c.certName.trim(),
@@ -372,6 +424,11 @@
         emit('saved', { mode: 'create', personnel: detail })
       }
       visible.value = false
+    } catch (e: any) {
+      const msg = e?.response?.data?.detail || e?.response?.data?.message || e?.message || ''
+      if (typeof msg === 'string' && msg) {
+        ElMessage.error(msg)
+      }
     } finally {
       submitLoading.value = false
     }

+ 85 - 4
web-admin/src/views/pl/personnel/index.vue

@@ -224,6 +224,18 @@
               </div>
             </template>
           </el-table-column>
+          <el-table-column
+            v-if="isColumnVisible('sysUserId')"
+            prop="sysUserId"
+            label="关联用户"
+            min-width="160"
+            align="center"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              {{ resolveUserDisplay(row.sysUserId) }}
+            </template>
+          </el-table-column>
           <el-table-column
             v-if="isColumnVisible('age')"
             prop="age"
@@ -338,6 +350,24 @@
           <el-form-item label="手机号" prop="phoneNo">
             <el-input v-model="formData.phoneNo" placeholder="请输入手机号(选填)" maxlength="32" />
           </el-form-item>
+          <el-form-item label="关联用户账号" prop="sysUserId">
+            <el-select
+              v-model="formData.sysUserId"
+              placeholder="可选,绑定小程序登录账号"
+              filterable
+              clearable
+              :loading="userSelectLoading"
+              style="width: 100%"
+              @focus="ensureUserOptions"
+            >
+              <el-option
+                v-for="user in userOptions"
+                :key="user.userId"
+                :label="user.displayName"
+                :value="user.userId"
+              />
+            </el-select>
+          </el-form-item>
           <el-form-item label="年龄" prop="age">
             <el-input-number
               v-model="formData.age"
@@ -466,6 +496,7 @@
 
 <script setup lang="ts">
 import { Check, Close, Delete, DocumentCopy, Download, Edit, Filter, Plus, User } from '@element-plus/icons-vue'
+import { fetchUserOptions } from '@/api/admin'
 import { personnelApi, type Personnel, type PersonnelDetail, type CertRow } from '@/api/pl'
 import PlCertFormList from '@/components/business/pl-cert-form-list/index.vue'
 import PlPasteRecognizeDialog from '@/components/business/pl-paste-recognize-dialog/index.vue'
@@ -535,6 +566,7 @@ const listColumnDefs = [
   { prop: 'personnelAffiliation', label: '人员归属' },
   { prop: 'personnelType', label: '人员类型' },
   { prop: 'phoneNo', label: '手机号' },
+  { prop: 'sysUserId', label: '关联用户' },
   { prop: 'age', label: '年龄' },
   { prop: 'idCardNo', label: '身份证号' },
   { prop: 'status', label: '状态' },
@@ -548,7 +580,40 @@ const {
   isIndeterminate,
   isColumnVisible,
   resetToDefault
-} = usePersistedListColumns('pl-personnel-list-columns-v3', listColumnDefs)
+} = usePersistedListColumns('pl-personnel-list-columns-v4', listColumnDefs)
+
+const userSelectLoading = ref(false)
+const userOptions = ref<Array<{ userId: string; nickName: string; displayName: string }>>([])
+
+async function ensureUserOptions() {
+  if (userOptions.value.length || userSelectLoading.value) return
+  userSelectLoading.value = true
+  try {
+    const res = await fetchUserOptions()
+    const data = (res as any)?.data ?? res
+    userOptions.value = (Array.isArray(data) ? data : []).map((u: Record<string, unknown>) => {
+      const userId = String(u.value ?? u.id ?? u.userId ?? '')
+      const name = String(u.label ?? u.nickName ?? u.userName ?? userId)
+      const account = String(u.userName ?? u.account ?? '').trim()
+      return {
+        userId,
+        nickName: name,
+        displayName: account && account !== name ? `${name}(${account})` : name
+      }
+    }).filter((u) => u.userId)
+  } catch {
+    userOptions.value = []
+  } finally {
+    userSelectLoading.value = false
+  }
+}
+
+function resolveUserDisplay(sysUserId?: string | null) {
+  const uid = (sysUserId || '').trim()
+  if (!uid) return '—'
+  const hit = userOptions.value.find((u) => u.userId === uid)
+  return hit?.displayName || uid
+}
 
 const {
   filters,
@@ -653,7 +718,8 @@ const formData = reactive({
   personnelAffiliation: 'self',
   status: '1',
   remark: '',
-  photoUrls: ''
+  photoUrls: '',
+  sysUserId: '' as string
 })
 
 const requiredPersonnelCertNames = computed(() =>
@@ -720,6 +786,7 @@ const viewSections = computed<ViewSection[]>(() => {
       fields: [
         { label: '姓名', value: d.personnelName },
         { label: '手机号', value: d.phoneNo || '—' },
+        { label: '关联用户', value: resolveUserDisplay(d.sysUserId) },
         {
           label: '人员归属',
           value: getPersonnelAffiliationLabel(d.personnelAffiliation),
@@ -919,10 +986,12 @@ function handleAdd() {
     personnelAffiliation: affiliationMode.value,
     status: '1',
     remark: '',
-    photoUrls: ''
+    photoUrls: '',
+    sysUserId: ''
   })
   resetCertList()
   dialogVisible.value = true
+  void ensureUserOptions()
 }
 
 async function handleView(row: Personnel) {
@@ -930,6 +999,7 @@ async function handleView(row: Personnel) {
   viewLoading.value = true
   viewDetail.value = null
   try {
+    await ensureUserOptions()
     const res = await personnelApi.get(row.id)
     viewDetail.value = ((res.data ?? res) || null) as PersonnelDetail | null
   } catch {
@@ -963,6 +1033,7 @@ async function handleEdit(row: Personnel) {
   isEdit.value = true
   dialogVisible.value = true
   try {
+    await ensureUserOptions()
     const res = await personnelApi.get(row.id)
     const detail = ((res.data ?? res) || {}) as PersonnelDetail
     Object.assign(formData, {
@@ -975,7 +1046,8 @@ async function handleEdit(row: Personnel) {
       personnelAffiliation: affiliationMode.value,
       status: detail.status,
       remark: detail.remark || '',
-      photoUrls: detail.photoUrls || ''
+      photoUrls: detail.photoUrls || '',
+      sysUserId: detail.sysUserId || ''
     })
     certList.value = mergePersonnelCertSlots(
 
@@ -1019,6 +1091,7 @@ async function handleSubmit() {
       status: formData.status,
       remark: formData.remark || undefined,
       photoUrls: formData.photoUrls ?? '',
+      sysUserId: (formData.sysUserId || '').trim() || null,
       certList: certList.value.map(c => ({
         certId: c.certId,
         certName: c.certName.trim(),
@@ -1037,6 +1110,13 @@ async function handleSubmit() {
     ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
     dialogVisible.value = false
     fetchData()
+  } catch (e: any) {
+    const msg = e?.response?.data?.detail || e?.response?.data?.message || e?.message || ''
+    if (typeof msg === 'string' && msg.includes('已关联')) {
+      ElMessage.error(msg)
+    } else if (msg) {
+      ElMessage.error(msg)
+    }
   } finally {
     submitLoading.value = false
   }
@@ -1063,6 +1143,7 @@ async function handleDelete(row: Personnel) {
 }
 
 onMounted(() => {
+  void ensureUserOptions()
   fetchData()
 
   nextTick(() => recalcTableHeight())