shinnosuke
2026-08-13 28407987700ab62540ff61c12098ab50f31ca546
fix(family-card): 修正管理页面并支持解绑
2个文件已添加
7个文件已修改
352 ■■■■■ 已修改文件
talent-card-admin/src/api/admin.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/router/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/types/admin.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/FamilyCardDetailView.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/FamilyCardView.vue 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/DependentCardApplyAdminController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/DependentCardApplyAdminService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/service/DependentCardApplyAdminServiceTests.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts
@@ -354,6 +354,10 @@
  })
}
export function unbindFamilyCard(id: number | string) {
  return http.patch<FamilyCardApplication, FamilyCardApplication>(`/api/admin/dependent-card-applies/${id}/unbind`)
}
export function getUpgradeApplications(params: UpgradeApplicationQuery = {}) {
  return http.get<PageResult<UpgradeApplication>, PageResult<UpgradeApplication>>(
    '/api/admin/cards/upgrade-applications',
talent-card-admin/src/router/index.ts
@@ -79,7 +79,7 @@
        {
          path: 'family-cards/:id',
          name: 'family-card-detail',
          component: () => import('../views/FamilyCardApplicationDetailView.vue'),
          component: () => import('../views/FamilyCardDetailView.vue'),
          meta: { title: '家属卡详情' },
        },
        {
talent-card-admin/src/style.css
@@ -2904,6 +2904,37 @@
  gap: 24px;
}
.family-card-detail-page {
  display: grid;
  gap: 20px;
}
.family-card-detail-panel {
  min-height: 430px;
  padding: 20px 18px 26px;
  background: #ffffff;
}
.reservation-detail-grid.family-card-owner-grid {
  min-height: 140px;
  align-content: start;
}
.reservation-detail-grid.family-card-member-grid {
  min-height: 112px;
  align-content: start;
}
.family-card-detail-actions {
  display: flex;
  align-items: center;
}
.family-card-unbound-tip {
  color: #909399;
  font-size: 13px;
}
.medical-reservation-detail-page {
  display: grid;
  gap: 24px;
@@ -3937,12 +3968,12 @@
}
.family-card-management-page .family-card-filter-form {
  grid-template-columns: minmax(250px, 1.05fr) minmax(230px, 0.9fr) minmax(320px, 1.25fr) 74px;
  grid-template-columns: minmax(250px, 1fr) minmax(280px, 1.1fr) minmax(220px, 0.9fr) 74px;
  gap: 12px 28px;
}
.family-card-filter-talent {
  grid-column: 1;
  grid-column: 2;
}
.family-card-filter-date {
talent-card-admin/src/types/admin.ts
@@ -619,6 +619,7 @@
  status?: string | number
  applyStart?: string
  applyEnd?: string
  management?: boolean
}
export interface ChildCardApplication {
talent-card-admin/src/views/FamilyCardDetailView.vue
New file
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getFamilyCardApplicationDetail, unbindFamilyCard } from '../api/admin'
import type { FamilyCardApplication } from '../types/admin'
const route = useRoute()
const detail = ref<FamilyCardApplication>()
const loading = ref(false)
const unbinding = ref(false)
function text(value?: string | number | null) {
  return value === undefined || value === null || value === '' ? '-' : String(value)
}
function genderText(gender?: number | string) {
  if (gender === 1 || gender === '1') return '男'
  if (gender === 2 || gender === '2') return '女'
  return '-'
}
const isBound = computed(() => detail.value?.status === 1 || detail.value?.status === '1')
const cardholderInfo = computed(() => [
  { label: '持卡人姓名:', value: text(detail.value?.talentName) },
  { label: '身份证号:', value: text(detail.value?.talentIdCardNo) },
  { label: '联系方式:', value: text(detail.value?.talentMobile) },
  { label: '联系地址:', value: text(detail.value?.contactAddress) },
  { label: '工作地址:', value: text(detail.value?.workAddress) },
  { label: '性别:', value: genderText(detail.value?.gender) },
  { label: '人才等级:', value: text(detail.value?.cardLevel) },
  { label: '当前家属卡数量:', value: `${detail.value?.currentFamilyCardCount ?? 0}/${detail.value?.familyCardLimit ?? 2}` },
])
const familyInfo = computed(() => [
  { label: '家属姓名:', value: text(detail.value?.familyName || detail.value?.dependentName) },
  { label: '身份证号:', value: text(detail.value?.idCardNo || detail.value?.dependentIdCardNo) },
  { label: '手机号:', value: text(detail.value?.familyMobile || detail.value?.dependentMobile) },
  { label: '与持卡人关系:', value: text(detail.value?.relationship || detail.value?.relation) },
])
async function loadDetail() {
  loading.value = true
  try {
    detail.value = await getFamilyCardApplicationDetail(String(route.params.id))
  } finally {
    loading.value = false
  }
}
async function unbind() {
  if (!detail.value) return
  await ElMessageBox.confirm('解除绑定后,该家属卡将无法继续使用。确定解除吗?', '解除绑定', {
    type: 'warning',
    confirmButtonText: '确定解绑',
    cancelButtonText: '取消',
  })
  unbinding.value = true
  try {
    detail.value = await unbindFamilyCard(detail.value.id)
    ElMessage.success('解除绑定成功')
  } finally {
    unbinding.value = false
  }
}
onMounted(loadDetail)
</script>
<template>
  <div v-loading="loading" class="family-card-detail-page">
    <section class="family-card-detail-panel">
      <div class="reservation-detail-section">
        <h3>持卡人信息</h3>
        <div class="reservation-detail-grid family-card-owner-grid">
          <div v-for="item in cardholderInfo" :key="item.label" class="reservation-detail-item">
            <span>{{ item.label }}</span><strong>{{ item.value }}</strong>
          </div>
        </div>
      </div>
      <div class="reservation-detail-section">
        <h3>家属信息</h3>
        <div class="reservation-detail-grid family-card-member-grid">
          <div v-for="item in familyInfo" :key="item.label" class="reservation-detail-item">
            <span>{{ item.label }}</span><strong>{{ item.value }}</strong>
          </div>
        </div>
      </div>
    </section>
    <div class="family-card-detail-actions">
      <el-button v-if="isBound" type="primary" :loading="unbinding" @click="unbind">解除绑定</el-button>
      <span v-else class="family-card-unbound-tip">该家属卡已解绑</span>
    </div>
  </div>
</template>
talent-card-admin/src/views/FamilyCardView.vue
@@ -11,7 +11,7 @@
const route = useRoute()
const router = useRouter()
const isManagement = computed(() => props.mode === 'management')
const emptyText = computed(() => '暂无家属卡申请数据')
const emptyText = computed(() => isManagement.value ? '暂无家属卡数据' : '暂无家属卡申请数据')
const filters = reactive({
  familyKeyword: '',
@@ -30,20 +30,30 @@
const loading = ref(false)
const rows = ref<FamilyCardApplication[]>([])
const statusOptions = [
const applicationStatusOptions = [
  { label: '待审核', value: 0 },
  { label: '已通过', value: 1 },
  { label: '已驳回', value: 2 },
  { label: '已取消', value: 3 },
]
const managementStatusOptions = [
  { label: '正常', value: 1 },
  { label: '已解绑', value: 3 },
]
const statusOptions = computed(() => isManagement.value ? managementStatusOptions : applicationStatusOptions)
const cardLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客']
function statusText(row: FamilyCardApplication) {
  if (isManagement.value) {
    if (row.status === 1 || row.status === '1') return '正常'
    if (row.status === 3 || row.status === '3') return '已解绑'
  }
  return row.statusText || '-'
}
function registerTime(row: FamilyCardApplication) {
  if (isManagement.value && row.auditTime) return row.auditTime
  return row.applyTime || row.createdAt || ''
}
@@ -69,12 +79,13 @@
    const result = await getFamilyCardApplications({
      page: pagination.page,
      size: pagination.size,
      familyKeyword: isManagement.value ? undefined : filters.familyKeyword || undefined,
      familyKeyword: filters.familyKeyword || undefined,
      talentKeyword: filters.talentKeyword || undefined,
      cardLevel: filters.cardLevel || undefined,
      status: filters.status,
      applyStart: filters.applyTime?.[0],
      applyEnd: filters.applyTime?.[1],
      management: isManagement.value || undefined,
    })
    rows.value = result.records
    pagination.total = result.total
@@ -105,24 +116,25 @@
  const result = await getFamilyCardApplications({
    page: 1,
    size: 1000,
    familyKeyword: isManagement.value ? undefined : filters.familyKeyword || undefined,
    familyKeyword: filters.familyKeyword || undefined,
    talentKeyword: filters.talentKeyword || undefined,
    cardLevel: filters.cardLevel || undefined,
    status: filters.status,
    applyStart: filters.applyTime?.[0],
    applyEnd: filters.applyTime?.[1],
    management: isManagement.value || undefined,
  })
  downloadExcel(
    isManagement.value ? '家属卡管理列表' : '家属卡申请列表',
    isManagement.value ? [
      { title: '申请编号', value: row => row.applicationNo || row.applyNo },
      { title: '持卡人姓名', value: row => row.talentName },
      { title: '持卡人手机号', value: row => row.talentMobile },
      { title: '卡片等级', value: row => row.cardLevel },
      { title: '编号', value: row => row.id },
      { title: '家属姓名', value: row => familyName(row) },
      { title: '家属手机号', value: row => familyMobile(row) },
      { title: '家属关系', value: row => relationship(row) },
      { title: '申请时间', value: row => registerTime(row) },
      { title: '持卡人姓名', value: row => row.talentName },
      { title: '持卡人手机号', value: row => row.talentMobile },
      { title: '人才卡号', value: row => row.talentCardNo },
      { title: '卡片等级', value: row => row.cardLevel },
      { title: '注册时间', value: row => registerTime(row) },
      { title: '状态', value: row => statusText(row) },
    ] : [
      { title: '申请编号', value: row => row.applicationNo || row.applyNo },
@@ -163,8 +175,8 @@
      </div>
      <el-form class="family-card-filter-form" :model="filters" label-width="84px">
        <el-form-item v-if="!isManagement" label="家属信息">
          <el-input v-model="filters.familyKeyword" placeholder="请输入家属姓名、手机号或身份证号" clearable />
        <el-form-item :label="isManagement ? '家属' : '家属信息'">
          <el-input v-model="filters.familyKeyword" :placeholder="isManagement ? '家属姓名、手机号' : '请输入家属姓名、手机号或身份证号'" clearable />
        </el-form-item>
        <el-form-item label="持卡人" :class="{ 'family-card-filter-talent': isManagement }">
          <el-input v-model="filters.talentKeyword" placeholder="持卡人姓名、手机号、卡号" clearable />
@@ -202,22 +214,18 @@
      </div>
      <el-table v-if="isManagement" v-loading="loading" :data="rows" class="family-card-table" :empty-text="emptyText">
        <el-table-column label="申请编号" min-width="112">
          <template #default="{ row }">{{ row.applicationNo || row.applyNo || row.id }}</template>
        </el-table-column>
        <el-table-column prop="talentName" label="持卡人名称" min-width="110" />
        <el-table-column prop="talentMobile" label="持卡人手机号" min-width="130" />
        <el-table-column prop="cardLevel" label="卡片等级" min-width="106" />
        <el-table-column prop="id" label="编号" width="82" align="center" />
        <el-table-column label="家属名称" min-width="106">
          <template #default="{ row }">{{ familyName(row) }}</template>
        </el-table-column>
        <el-table-column label="家属手机号" min-width="130">
          <template #default="{ row }">{{ familyMobile(row) }}</template>
        </el-table-column>
        <el-table-column label="家属关系" min-width="102">
          <template #default="{ row }">{{ relationship(row) }}</template>
        </el-table-column>
        <el-table-column label="申请时间" min-width="150">
        <el-table-column prop="talentName" label="持卡人名称" min-width="110" />
        <el-table-column prop="talentMobile" label="持卡人手机号" min-width="130" />
        <el-table-column prop="talentCardNo" label="人才卡号" min-width="120" />
        <el-table-column prop="cardLevel" label="卡片等级" min-width="108" />
        <el-table-column label="注册时间" min-width="150">
          <template #default="{ row }">{{ registerTime(row) }}</template>
        </el-table-column>
        <el-table-column label="状态" min-width="90">
talent-card-server/src/main/java/com/talentcard/server/business/admin/DependentCardApplyAdminController.java
@@ -45,10 +45,11 @@
            @Parameter(description = "状态:0待审核、1已通过、2已驳回、3已取消") @RequestParam(required = false) Integer status,
            @Parameter(description = "申请开始日期") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applyStart,
            @Parameter(description = "申请结束日期") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applyEnd,
            @Parameter(description = "是否按家属卡管理范围查询(仅已绑定和已解绑)") @RequestParam(defaultValue = "false") boolean management,
            @Parameter(description = "页码") @RequestParam(defaultValue = "1") int page,
            @Parameter(description = "每页数量,最大100") @RequestParam(defaultValue = "10") int size
    ) {
        return ApiResponse.success(service.page(talentCardId, familyKeyword, talentKeyword, cardLevel, status, applyStart, applyEnd, page, size));
        return ApiResponse.success(service.page(talentCardId, familyKeyword, talentKeyword, cardLevel, status, applyStart, applyEnd, management, page, size));
    }
    @Operation(summary = "查询家属附属卡申请详情")
@@ -71,9 +72,18 @@
        return ApiResponse.success(service.audit(id, request, operatorId));
    }
    @Operation(summary = "解除家属卡绑定", description = "仅正常状态的家属卡可以解除绑定")
    @PatchMapping("/{id}/unbind")
    public ApiResponse<DependentCardApplyAdminResponse> unbind(
            @PathVariable Long id,
            @RequestAttribute(AdminAuthInterceptor.OPERATOR_ID_ATTRIBUTE) Long operatorId
    ) {
        return ApiResponse.success(service.unbind(id, operatorId));
    }
    @Operation(summary = "查询家属附属卡申请状态记录")
    @GetMapping("/{id}/status-logs")
    public ApiResponse<List<DependentCardApplyStatusLogResponse>> statusLogs(@PathVariable Long id) {
        return ApiResponse.success(service.statusLogs(id));
    }
}
}
talent-card-server/src/main/java/com/talentcard/server/service/DependentCardApplyAdminService.java
@@ -50,10 +50,16 @@
            Integer status,
            LocalDate applyStart,
            LocalDate applyEnd,
            boolean management,
            int page,
            int size
    ) {
        validateStatusFilter(status);
        if (management && status != null
                && status != DependentCardApply.STATUS_APPROVED
                && status != DependentCardApply.STATUS_CANCELLED) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "家属卡管理状态只能为正常或已解绑");
        }
        if (applyStart != null && applyEnd != null && applyStart.isAfter(applyEnd)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "申请开始时间不能晚于结束时间");
        }
@@ -62,6 +68,8 @@
        LambdaQueryWrapper<DependentCardApply> query = Wrappers.<DependentCardApply>lambdaQuery()
                .eq(talentCardId != null, DependentCardApply::getTalentCardId, talentCardId)
                .eq(status != null, DependentCardApply::getStatus, status)
                .in(management && status == null, DependentCardApply::getStatus,
                        DependentCardApply.STATUS_APPROVED, DependentCardApply.STATUS_CANCELLED)
                .eq(cardLevel != null && !cardLevel.isBlank(), DependentCardApply::getCardLevel, trimToNull(cardLevel))
                .and(familyKeyword != null && !familyKeyword.isBlank(), wrapper -> wrapper
                        .like(DependentCardApply::getDependentName, familyKeyword.trim())
@@ -165,6 +173,40 @@
        return DependentCardApplyAdminResponse.from(updatedApply, applicationExtra(updatedApply));
    }
    @Transactional
    public DependentCardApplyAdminResponse unbind(Long id, Long operatorId) {
        DependentCardApply apply = mapper.selectByIdForUpdate(id);
        if (apply == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "家属卡不存在");
        }
        if (!Integer.valueOf(DependentCardApply.STATUS_APPROVED).equals(apply.getStatus())) {
            throw new BusinessException(ErrorCode.CONFLICT, "仅正常状态的家属卡可以解除绑定");
        }
        int updated = mapper.update(
                null,
                Wrappers.<DependentCardApply>lambdaUpdate()
                        .eq(DependentCardApply::getId, id)
                        .eq(DependentCardApply::getStatus, DependentCardApply.STATUS_APPROVED)
                        .set(DependentCardApply::getStatus, DependentCardApply.STATUS_CANCELLED)
        );
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "家属卡状态已变化,请刷新后重试");
        }
        if (statusLogMapper.insert(new DependentCardApplyStatusLog(
                apply.getId(),
                apply.getApplyNo(),
                DependentCardApply.STATUS_APPROVED,
                DependentCardApply.STATUS_CANCELLED,
                "ADMIN",
                operatorId,
                "管理后台解除绑定"
        )) != 1) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "解绑状态记录保存失败");
        }
        DependentCardApply updatedApply = mapper.selectById(id);
        return DependentCardApplyAdminResponse.from(updatedApply, applicationExtra(updatedApply));
    }
    private Map<String, Object> applicationExtra(DependentCardApply apply) {
        if (apply == null || apply.getTalentCardId() == null) {
            return Map.of();
talent-card-server/src/test/java/com/talentcard/server/service/DependentCardApplyAdminServiceTests.java
New file
@@ -0,0 +1,108 @@
package com.talentcard.server.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.entity.DependentCardApply;
import com.talentcard.server.entity.DependentCardApplyStatusLog;
import com.talentcard.server.mapper.DependentCardApplyMapper;
import com.talentcard.server.mapper.DependentCardApplyStatusLogMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.jdbc.core.JdbcTemplate;
@ExtendWith(MockitoExtension.class)
class DependentCardApplyAdminServiceTests {
    @Mock
    private DependentCardApplyMapper mapper;
    @Mock
    private DependentCardApplyStatusLogMapper statusLogMapper;
    @Mock
    private JdbcTemplate jdbcTemplate;
    private DependentCardApplyAdminService service;
    @BeforeEach
    void setUp() {
        TableInfoHelper.initTableInfo(
                new MapperBuilderAssistant(new MybatisConfiguration(), "dependent-card-admin-test"),
                DependentCardApply.class
        );
        service = new DependentCardApplyAdminService(mapper, statusLogMapper, jdbcTemplate);
    }
    @Test
    void unbindChangesApprovedCardToCancelledAndRecordsAdminOperator() {
        DependentCardApply approved = applicationWithStatus(DependentCardApply.STATUS_APPROVED);
        DependentCardApply cancelled = applicationWithStatus(DependentCardApply.STATUS_CANCELLED);
        when(mapper.selectByIdForUpdate(77L)).thenReturn(approved);
        when(mapper.update(any(), any(Wrapper.class))).thenReturn(1);
        when(statusLogMapper.insert(any(DependentCardApplyStatusLog.class))).thenReturn(1);
        when(mapper.selectById(77L)).thenReturn(cancelled);
        when(jdbcTemplate.queryForList(anyString(), eq(77L))).thenReturn(List.of());
        var result = service.unbind(77L, 9L);
        assertThat(result.status()).isEqualTo(DependentCardApply.STATUS_CANCELLED);
        ArgumentCaptor<DependentCardApplyStatusLog> logCaptor = ArgumentCaptor.forClass(DependentCardApplyStatusLog.class);
        verify(statusLogMapper).insert(logCaptor.capture());
        DependentCardApplyStatusLog log = logCaptor.getValue();
        assertThat(log.getFromStatus()).isEqualTo(DependentCardApply.STATUS_APPROVED);
        assertThat(log.getToStatus()).isEqualTo(DependentCardApply.STATUS_CANCELLED);
        assertThat(log.getOperatorType()).isEqualTo("ADMIN");
        assertThat(log.getOperatorId()).isEqualTo(9L);
    }
    @Test
    void unbindRejectsCardThatIsNotApproved() {
        when(mapper.selectByIdForUpdate(77L)).thenReturn(applicationWithStatus(DependentCardApply.STATUS_REJECTED));
        assertThatThrownBy(() -> service.unbind(77L, 9L))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("仅正常状态");
    }
    @Test
    void managementPageRejectsApplicationOnlyStatus() {
        assertThatThrownBy(() -> service.page(null, null, null, null, 0, null, null, true, 1, 10))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("正常或已解绑");
    }
    private DependentCardApply applicationWithStatus(int status) {
        DependentCardApply apply = new DependentCardApply(
                "DCA-001", 1L, 2L, "持卡人", "13900000000", "TC-001", "卓越人才卡",
                "家属", "13800000000", "510105199001011234", "配偶"
        );
        setField(apply, "id", 77L);
        setField(apply, "status", status);
        return apply;
    }
    private void setField(DependentCardApply apply, String name, Object value) {
        try {
            var field = DependentCardApply.class.getDeclaredField(name);
            field.setAccessible(true);
            field.set(apply, value);
        } catch (ReflectiveOperationException exception) {
            throw new AssertionError(exception);
        }
    }
}