zjk
2026-08-06 58b9955666d6b0b7972bcab367b8a3a192e9cdf8
feat(admin): 完善车牌管理,重构菜单并新增电影票包管理功能

- 移除车牌列表页查看按钮的applicationNo判断,始终展示查看按钮
- 重构后台菜单配置:调整电影票管理为一级菜单,优化默认展开逻辑,新增菜单唯一展开特性,配置领取记录页面的激活菜单匹配规则
- 重构车牌详情页的数据渲染逻辑与布局样式
- 完全重写电影票管理页面,实现完整的票包管理流程:列表查询、分页、新增、查看、状态切换、数据导出及跳转领取记录
- 新增相关页面的CSS样式适配新布局
5个文件已修改
617 ■■■■■ 已修改文件
talent-card-admin/src/layouts/AdminLayout.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MovieTicketView.vue 396 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ParkingPlateDetailView.vue 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ParkingPlateView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/layouts/AdminLayout.vue
@@ -105,14 +105,7 @@
  { path: '/equity-images', label: '权益图片管理' },
  { path: '/talent-news-management', label: '人才资讯管理' },
  { path: '/talent-policy-management', label: '人才政策管理' },
  {
    index: 'movie-ticket-management',
    label: '电影票管理',
    children: [
      { path: '/movie-ticket-records', label: '电影票领取记录' },
      { path: '/movie-tickets', label: '电影票管理' },
    ],
  },
  { path: '/movie-tickets', label: '电影票管理' },
]
const systemMenus: MenuItem[] = [
@@ -131,10 +124,10 @@
  { index: 'system-management', label: '系统管理', icon: Setting, items: systemMenus },
]
const defaultOpeneds = [
  ...menuGroups.map((item) => item.index),
  'movie-ticket-management',
]
const defaultOpeneds = computed(() => {
  const currentGroup = menuGroups.find((item) => findMenuLabel(item.items, activeMenu.value))
  return currentGroup ? [currentGroup.index] : []
})
const pageTitle = computed(() => {
  if (route.name === 'merchant-create' && route.query.mode === 'edit') {
@@ -179,6 +172,9 @@
  }
  if (route.path === '/talent-policies' || route.path === '/talent-policy-categories') {
    return '/talent-policy-management'
  }
  if (route.path === '/movie-ticket-records') {
    return '/movie-tickets'
  }
  return route.path
})
@@ -301,6 +297,7 @@
        router
        :default-active="activeMenu"
        :default-openeds="defaultOpeneds"
        unique-opened
        class="admin-menu"
      >
        <el-menu-item index="/dashboard">
talent-card-admin/src/style.css
@@ -2754,6 +2754,10 @@
  align-content: start;
}
.parking-plate-submit-time {
  grid-column: 1 / 2;
}
.reservation-detail-grid.medical-application-info-grid {
  min-height: 154px;
  align-content: start;
@@ -3080,6 +3084,128 @@
  font-size: 12px;
}
.movie-ticket-package-page .panel {
  min-height: 640px;
  padding: 18px 14px 20px;
}
.movie-ticket-package-filter-form {
  display: grid;
  grid-template-columns: minmax(320px, 1fr) minmax(240px, 1fr) minmax(320px, 1fr) minmax(240px, 1fr) auto;
  gap: 14px 28px;
  align-items: start;
  padding-bottom: 18px;
  border-bottom: 1px solid #d9dee8;
}
.movie-ticket-package-filter-form .el-form-item {
  margin-bottom: 0;
}
.movie-ticket-package-filter-form .el-input,
.movie-ticket-package-filter-form .el-select,
.movie-ticket-package-filter-form .el-date-editor {
  width: 100%;
}
.movie-ticket-package-filter-actions {
  grid-row: 1 / span 2;
  grid-column: 5;
  display: grid;
  gap: 10px;
  align-content: start;
  min-width: 80px;
}
.movie-ticket-package-tools {
  display: flex;
  justify-content: space-between;
  padding: 18px 0 16px;
}
.movie-ticket-package-table {
  color: #111827;
  font-size: 12px;
}
.movie-ticket-package-table.el-table {
  border-radius: 0;
}
.movie-ticket-package-table .el-table__cell {
  padding: 5px 0;
}
.movie-ticket-package-table .el-table__header th {
  height: 34px;
  color: #111827;
  background: #f3f4f6;
  font-weight: 700;
}
.movie-ticket-package-table .el-button {
  padding: 0 2px;
  font-size: 12px;
}
.movie-ticket-create-page {
  display: grid;
  gap: 12px;
}
.movie-ticket-create-panel {
  min-height: 286px;
  padding: 18px 16px 28px;
  background: #ffffff;
}
.movie-ticket-create-panel h3 {
  margin: 0 0 18px;
  color: #111827;
  font-size: 15px;
  font-weight: 700;
}
.movie-ticket-create-form {
  margin-bottom: 28px;
}
.movie-ticket-create-row {
  display: grid;
  grid-template-columns: 58px 180px 36px 160px 86px;
  gap: 12px;
  align-items: center;
  color: #111827;
  font-size: 13px;
}
.movie-ticket-create-row .el-input {
  width: 100%;
}
.movie-ticket-benefit-list {
  display: grid;
  gap: 18px;
}
.movie-ticket-benefit-row {
  display: grid;
  grid-template-columns: 86px 40px 56px 80px 20px 56px 90px;
  gap: 12px;
  align-items: center;
  color: #111827;
  font-size: 13px;
}
.movie-ticket-benefit-row strong {
  font-weight: 700;
}
.movie-ticket-benefit-row .el-input,
.movie-ticket-benefit-row .el-select {
  width: 100%;
}
.news-publish-page {
  min-height: calc(100vh - 126px);
  background: #ffffff;
talent-card-admin/src/views/MovieTicketView.vue
@@ -1,89 +1,347 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Download, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getMovieTicketRules, saveMovieTicketRules } from '../api/admin'
import type { MovieTicketRule } from '../types/admin'
import { downloadExcel } from '../utils/excel'
const resetCycles = ['每天', '每周', '每月', '每季', '每年']
interface MovieTicketPackageRow {
    id: number | string
    packageNo: string
    packageName: string
    merchants: string
    totalCount: number
    claimedCount: number
    remainCount: number
    status: '正常' | '禁用'
    talentKeyword?: string
    cardLevel?: string
    orderDate?: string
}
interface MovieTicketBenefitForm {
    level: string
    enabled: boolean
    claimableCount: string
    resetCycle: string
}
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const pageMode = ref<'list' | 'create' | 'view'>('list')
const rules = ref<MovieTicketRule[]>([
  {
    level: '卓越人才卡',
    enabled: true,
    claimableCount: '',
    resetCycle: '每月',
  },
  {
    level: '杰出人才卡',
    enabled: true,
    claimableCount: '',
    resetCycle: '每月',
  },
  {
    level: '菁英人才卡',
    enabled: true,
    claimableCount: '',
    resetCycle: '每月',
  },
const cardLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '青年人才卡']
const statusOptions = ['正常', '禁用']
const resetCycles = ['每天', '每周', '每月', '每季', '每年']
const filters = reactive({
    talentKeyword: '',
    cardLevel: '',
    orderTime: [] as string[],
    status: '',
})
const pagination = reactive({
    page: 1,
    size: 10,
    total: 0,
})
const movieTicketPackageSource = ref<MovieTicketPackageRow[]>([
    {
        id: 1,
        packageNo: '123464561',
        packageName: '赵四',
        merchants: 'XXX电影院、XXXX电影院',
        totalCount: 12354,
        claimedCount: 25,
        remainCount: 1564,
        status: '正常',
        talentKeyword: '赵四 13800000000 TC0001',
        cardLevel: '卓越人才卡',
        orderDate: '2026-06-18',
    },
])
function normalizeRule(rule: MovieTicketRule): MovieTicketRule {
  return {
    ...rule,
    enabled: rule.enabled === true || rule.enabled === 1,
    claimableCount:
      rule.claimableCount === '' || rule.claimableCount === undefined ? undefined : Number(rule.claimableCount),
    resetCycle: rule.resetCycle || '每月',
  }
const createForm = reactive({
    packageName: '',
    totalCount: '',
})
const benefitForm = ref<MovieTicketBenefitForm[]>(defaultBenefitForm())
const isReadonlyMode = computed(() => pageMode.value === 'view')
function defaultBenefitForm(): MovieTicketBenefitForm[] {
    return [
        { level: '卓越人才卡', enabled: true, claimableCount: '', resetCycle: '每天' },
        { level: '菁英人才卡', enabled: true, claimableCount: '', resetCycle: '每天' },
        { level: '杰出人才卡', enabled: true, claimableCount: '', resetCycle: '每天' },
    ]
}
async function loadRules() {
  loading.value = true
  try {
    const result = await getMovieTicketRules()
    if (result.length) {
      rules.value = result.map(normalizeRule)
const filteredRows = computed(() => {
    return movieTicketPackageSource.value.filter((item) => {
        const keywordMatched = !filters.talentKeyword
            || `${item.talentKeyword || ''} ${item.packageName} ${item.packageNo}`.includes(filters.talentKeyword.trim())
        const levelMatched = !filters.cardLevel || item.cardLevel === filters.cardLevel
        const statusMatched = !filters.status || item.status === filters.status
        const [startDate, endDate] = filters.orderTime
        const timeMatched = !startDate || !endDate || !item.orderDate || (item.orderDate >= startDate && item.orderDate <= endDate)
        return keywordMatched && levelMatched && statusMatched && timeMatched
    })
})
const pagedRows = computed(() => {
    const start = (pagination.page - 1) * pagination.size
    return filteredRows.value.slice(start, start + pagination.size)
})
function loadData() {
    loading.value = true
    try {
        pagination.total = filteredRows.value.length
    } finally {
        loading.value = false
    }
  } finally {
    loading.value = false
  }
}
async function saveRules() {
  saving.value = true
  try {
    rules.value = (await saveMovieTicketRules(rules.value.map(normalizeRule))).map(normalizeRule)
  } finally {
    saving.value = false
  }
  ElMessage.success('电影票规则已保存')
function search() {
    pagination.page = 1
    loadData()
}
onMounted(loadRules)
function resetFilters() {
    filters.talentKeyword = ''
    filters.cardLevel = ''
    filters.orderTime = []
    filters.status = ''
    pagination.page = 1
    loadData()
}
function openCreate() {
    pageMode.value = 'create'
    resetCreateForm()
}
function resetCreateForm() {
    createForm.packageName = ''
    createForm.totalCount = ''
    benefitForm.value = defaultBenefitForm()
}
function openAvailableMerchants() {
    ElMessage.info('可用商家配置暂未接入')
}
function fillViewForm(row: MovieTicketPackageRow) {
    createForm.packageName = row.packageName
    createForm.totalCount = String(row.totalCount)
    benefitForm.value = defaultBenefitForm().map((item) => ({
        ...item,
        enabled: item.level === row.cardLevel,
        claimableCount: item.level === row.cardLevel ? String(row.remainCount) : '',
    }))
}
function saveCreate() {
    if (!createForm.packageName.trim()) {
        ElMessage.warning('请输入票包名称')
        return
    }
    if (!createForm.totalCount.trim()) {
        ElMessage.warning('请输入数量')
        return
    }
    const totalCount = Number(createForm.totalCount)
    if (!Number.isFinite(totalCount) || totalCount <= 0) {
        ElMessage.warning('数量需为大于 0 的数字')
        return
    }
    const invalidBenefit = benefitForm.value.find((item) => item.enabled && !item.claimableCount.trim())
    if (invalidBenefit) {
        ElMessage.warning(`请输入${invalidBenefit.level}可领数量`)
        return
    }
    movieTicketPackageSource.value.unshift({
        id: Date.now(),
        packageNo: String(Date.now()),
        packageName: createForm.packageName.trim(),
        merchants: '待配置',
        totalCount,
        claimedCount: 0,
        remainCount: totalCount,
        status: '正常',
        talentKeyword: '',
        cardLevel: benefitForm.value.find((item) => item.enabled)?.level || '',
        orderDate: new Date().toISOString().slice(0, 10),
    })
    pageMode.value = 'list'
    pagination.page = 1
    loadData()
    ElMessage.success('票包已新增')
}
function toggleStatus(row: MovieTicketPackageRow) {
    row.status = row.status === '正常' ? '禁用' : '正常'
    ElMessage.success(`已${row.status === '正常' ? '启用' : '禁用'}该票包`)
    loadData()
}
function viewDetail(row: MovieTicketPackageRow) {
    fillViewForm(row)
    pageMode.value = 'view'
}
function openReceiveRecords() {
    router.push('/movie-ticket-records')
}
function backToList() {
    pageMode.value = 'list'
}
function download() {
    downloadExcel(
        '电影票管理',
        [
            { title: '票包编号', value: row => row.packageNo },
            { title: '票包名称', value: row => row.packageName },
            { title: '适用商家', value: row => row.merchants },
            { title: '数量', value: row => row.totalCount },
            { title: '领取数量', value: row => row.claimedCount },
            { title: '剩余', value: row => row.remainCount },
            { title: '状态', value: row => row.status },
        ],
        filteredRows.value,
    )
    ElMessage.success(`已导出 ${filteredRows.value.length} 条电影票数据`)
}
onMounted(loadData)
</script>
<template>
  <div v-loading="loading" class="movie-ticket-config-page">
    <section class="movie-ticket-config-panel">
      <h3>人才权益</h3>
    <div v-if="pageMode === 'list'" class="page-stack prototype-page movie-ticket-package-page">
        <section class="panel">
            <div class="panel-header">
                <h3>查询表格</h3>
            </div>
      <div class="movie-ticket-rule-list">
        <div v-for="rule in rules" :key="rule.level" class="movie-ticket-rule-row">
          <strong>{{ rule.level }}</strong>
          <el-switch v-model="rule.enabled" />
          <span>可领数量</span>
          <el-input v-model="rule.claimableCount" placeholder="请输入" />
          <span>张</span>
          <span class="reset-label">重置周期</span>
          <el-select v-model="rule.resetCycle">
            <el-option v-for="cycle in resetCycles" :key="cycle" :label="cycle" :value="cycle" />
          </el-select>
        </div>
      </div>
    </section>
            <el-form class="movie-ticket-package-filter-form" :model="filters" label-width="72px">
                <el-form-item label="持卡人">
                    <el-input v-model="filters.talentKeyword" placeholder="持卡人姓名、手机号、卡号" clearable />
                </el-form-item>
                <el-form-item label="卡片等级">
                    <el-select v-model="filters.cardLevel" placeholder="全部" clearable>
                        <el-option label="全部" value="" />
                        <el-option v-for="level in cardLevels" :key="level" :label="level" :value="level" />
                    </el-select>
                </el-form-item>
                <el-form-item label="订单时间">
                    <el-date-picker
                        v-model="filters.orderTime"
                        type="daterange"
                        start-placeholder="开始日期"
                        end-placeholder="结束日期"
                        range-separator="至"
                        value-format="YYYY-MM-DD"
                    />
                </el-form-item>
                <el-form-item label="状态">
                    <el-select v-model="filters.status" placeholder="全部" clearable>
                        <el-option label="全部" value="" />
                        <el-option v-for="status in statusOptions" :key="status" :label="status" :value="status" />
                    </el-select>
                </el-form-item>
                <div class="movie-ticket-package-filter-actions">
                    <el-button type="primary" :icon="Search" @click="search">查询</el-button>
                    <el-button :icon="Refresh" @click="resetFilters">重置</el-button>
                </div>
            </el-form>
    <el-button type="primary" class="movie-ticket-save-button" :loading="saving" @click="saveRules">保存</el-button>
  </div>
            <div class="movie-ticket-package-tools">
                <el-button type="primary" :icon="Plus" @click="openCreate">新增</el-button>
                <el-button :icon="Download" @click="download">下载</el-button>
            </div>
            <el-table
                v-loading="loading"
                :data="pagedRows"
                border
                class="movie-ticket-package-table"
                empty-text="暂无电影票数据"
            >
                <el-table-column prop="packageNo" label="票包编号" min-width="120" align="center" />
                <el-table-column prop="packageName" label="票包名称" min-width="140" align="center" />
                <el-table-column prop="merchants" label="适用商家" min-width="150" align="center" show-overflow-tooltip />
                <el-table-column prop="totalCount" label="数量" min-width="90" align="center" />
                <el-table-column prop="claimedCount" label="领取数量" min-width="110" align="center" />
                <el-table-column prop="remainCount" label="剩余" min-width="90" align="center" />
                <el-table-column prop="status" label="状态" min-width="90" align="center" />
                <el-table-column label="操作" min-width="160" align="center">
                    <template #default="{ row }">
                        <el-button link type="primary" @click="toggleStatus(row)">{{ row.status === '正常' ? '禁用' : '启用' }}</el-button>
                        <el-button link type="primary" @click="viewDetail(row)">查看</el-button>
                        <el-button link type="primary" @click="openReceiveRecords">领取记录</el-button>
                    </template>
                </el-table-column>
            </el-table>
            <div class="pagination-row">
                <el-pagination
                    v-model:current-page="pagination.page"
                    v-model:page-size="pagination.size"
                    background
                    layout="total, prev, pager, next"
                    :pager-count="5"
                    :total="pagination.total"
                    @change="loadData"
                />
            </div>
        </section>
    </div>
    <div v-else class="page-stack prototype-page movie-ticket-create-page">
        <section class="panel movie-ticket-create-panel">
            <h3>票包信息</h3>
            <div class="movie-ticket-create-form">
                <div class="movie-ticket-create-row">
                    <label>票包名称</label>
                    <el-input v-if="!isReadonlyMode" v-model="createForm.packageName" placeholder="攀枝花万达" />
                    <span v-else>{{ createForm.packageName || '-' }}</span>
                    <label>数量</label>
                    <el-input v-if="!isReadonlyMode" v-model="createForm.totalCount" placeholder="888" />
                    <span v-else>{{ createForm.totalCount || '-' }}</span>
                    <el-button v-if="!isReadonlyMode" type="primary" @click="openAvailableMerchants">可用商家</el-button>
                    <span v-else>可用商家</span>
                </div>
            </div>
            <h3>人才权益</h3>
            <div class="movie-ticket-benefit-list">
                <div v-for="item in benefitForm" :key="item.level" class="movie-ticket-benefit-row">
                    <strong>{{ item.level }}</strong>
                    <el-switch v-if="!isReadonlyMode" v-model="item.enabled" />
                    <span v-else>{{ item.enabled ? '开启' : '关闭' }}</span>
                    <span>可领数量</span>
                    <el-input v-if="!isReadonlyMode" v-model="item.claimableCount" placeholder="请输入" />
                    <span v-else>{{ item.claimableCount || '-' }}</span>
                    <span>张</span>
                    <span>重置周期</span>
                    <el-select v-if="!isReadonlyMode" v-model="item.resetCycle">
                        <el-option v-for="cycle in resetCycles" :key="cycle" :label="cycle" :value="cycle" />
                    </el-select>
                    <span v-else>{{ item.resetCycle }}</span>
                </div>
            </div>
        </section>
        <template v-if="isReadonlyMode">
            <el-button class="movie-ticket-save-button" @click="backToList">返回</el-button>
        </template>
        <el-button v-else type="primary" class="movie-ticket-save-button" @click="saveCreate">保存</el-button>
    </div>
</template>
talent-card-admin/src/views/ParkingPlateDetailView.vue
@@ -13,23 +13,23 @@
  return value === undefined || value === null || value === '' ? '-' : String(value)
}
const cardholderInfo = computed(() => [
  { label: '持卡人姓名:', value: text(detail.value?.talentName) },
  { label: '手机号:', value: text(detail.value?.talentMobile) },
  { label: '人才等级:', value: text(detail.value?.talentLevel) },
])
const cardholderInfo = computed(() => ({
  talentName: text(detail.value?.talentName),
  talentMobile: text(detail.value?.talentMobile),
  talentLevel: text(detail.value?.talentLevel),
}))
const submissionInfo = computed(() => [
  { label: '车牌号:', value: text(detail.value?.plateNo) },
  { label: '停车场:', value: text(detail.value?.parkingLot) },
  { label: '提交时间:', value: text(detail.value?.submittedAt) },
])
const submissionInfo = computed(() => ({
  plateNo: text(detail.value?.plateNo),
  parkingLot: text(detail.value?.parkingLot),
  submittedAt: text(detail.value?.submittedAt),
}))
const statusInfo = computed(() => [
  { label: '状态:', value: text(detail.value?.status) },
  { label: '处理时间:', value: text(detail.value?.handledAt) },
  { label: '处理人:', value: text(detail.value?.handlerName) },
])
const statusInfo = computed(() => ({
  status: text(detail.value?.status),
  handledAt: text(detail.value?.handledAt),
  handlerName: text(detail.value?.handlerName),
}))
const handled = computed(() => detail.value?.status === '已处理')
@@ -56,9 +56,17 @@
      <div class="reservation-detail-section">
        <h3>持卡人信息</h3>
        <div class="reservation-detail-grid single-line">
          <div v-for="item in cardholderInfo" :key="item.label" class="reservation-detail-item">
            <span>{{ item.label }}</span>
            <strong>{{ item.value }}</strong>
          <div class="reservation-detail-item">
            <span>持卡人姓名:</span>
            <strong>{{ cardholderInfo.talentName }}</strong>
          </div>
          <div class="reservation-detail-item">
            <span>手机号:</span>
            <strong>{{ cardholderInfo.talentMobile }}</strong>
          </div>
          <div class="reservation-detail-item">
            <span>人才等级:</span>
            <strong>{{ cardholderInfo.talentLevel }}</strong>
          </div>
        </div>
      </div>
@@ -66,9 +74,17 @@
      <div class="reservation-detail-section">
        <h3>提交信息</h3>
        <div class="reservation-detail-grid parking-plate-submit-grid">
          <div v-for="item in submissionInfo" :key="item.label" class="reservation-detail-item">
            <span>{{ item.label }}</span>
            <strong>{{ item.value }}</strong>
          <div class="reservation-detail-item">
            <span>车牌号:</span>
            <strong>{{ submissionInfo.plateNo }}</strong>
          </div>
          <div class="reservation-detail-item">
            <span>停车场:</span>
            <strong>{{ submissionInfo.parkingLot }}</strong>
          </div>
          <div class="reservation-detail-item parking-plate-submit-time">
            <span>提交时间:</span>
            <strong>{{ submissionInfo.submittedAt }}</strong>
          </div>
        </div>
      </div>
@@ -76,9 +92,17 @@
      <div class="reservation-detail-section">
        <h3>状态信息</h3>
        <div class="reservation-detail-grid single-line">
          <div v-for="item in statusInfo" :key="item.label" class="reservation-detail-item">
            <span>{{ item.label }}</span>
            <strong>{{ item.value }}</strong>
          <div class="reservation-detail-item">
            <span>状态:</span>
            <strong>{{ statusInfo.status }}</strong>
          </div>
          <div class="reservation-detail-item">
            <span>处理时间:</span>
            <strong>{{ statusInfo.handledAt }}</strong>
          </div>
          <div class="reservation-detail-item">
            <span>处理人:</span>
            <strong>{{ statusInfo.handlerName }}</strong>
          </div>
        </div>
      </div>
talent-card-admin/src/views/ParkingPlateView.vue
@@ -154,7 +154,7 @@
        <el-table-column prop="status" label="状态" min-width="108" align="center" />
        <el-table-column label="操作" width="86" fixed="right" align="center">
          <template #default="{ row }">
            <el-button v-if="row.applicationNo" link type="primary" @click="viewDetail(row)">查看</el-button>
            <el-button link type="primary" @click="viewDetail(row)">查看</el-button>
          </template>
        </el-table-column>
      </el-table>