zjk
2026-02-13 d5cd902d3cd57f25efc47e75afad8abc0a14a3d8
feat: 人员综合审核、内容中心
12个文件已添加
5个文件已修改
2094 ■■■■■ 已修改文件
src/assets/service/phone.png 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/cruise/patrol-points/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/plan/components/BatchSetModal.vue 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/plan/components/CopyScheduleModal.vue 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/plan/components/EditMonthModal.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/plan/copy.vue 467 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/plan/edit.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/plan/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/message/index.vue 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/review/personnel/components/ReviewModal.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/review/personnel/index.vue 452 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/user-content/content-center/components/ContentForm.vue 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/user-content/content-center/components/FaqForm.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/user-content/content-center/components/SimpleRichTextForm.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/user-content/content-center/components/VehiclePrepareForm.vue 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/user-content/content-center/index.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/service/phone.png
src/router/routes.static.ts
@@ -467,6 +467,17 @@
    pid: null,
  },
  {
    name: 'operationsMessage',
    path: '/operations/message',
    title: '消息通知管理',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/operations/message/index.vue',
    id: 900,
    pid: 9,
  },
  {
    name: 'operationsPersonnel',
    path: '/operations/personnel',
    title: '运维人员管理',
@@ -569,5 +580,83 @@
    pid: 902,
    hide: true,
    activeMenu: '/operations/attendance/plan',
  },{
    name: 'operationsAttendancePlanCopy',
    path: '/operations/attendance/plan/copy',
    title: '复制排班',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/operations/attendance/plan/copy.vue',
    id: 9026,
    pid: 902,
    hide: true,
    activeMenu: '/operations/attendance/plan',
  },
  {
    name: 'service',
    path: '/service',
    title: '服务中心',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'dir',
    componentPath: null,
    id: 10,
    pid: null,
  },
  {
    name: 'serviceReview',
    path: '/service/review',
    title: '综合审核',
    requiresAuth: true,
    icon: 'icon-park-outline:check-one',
    menuType: 'dir',
    componentPath: null,
    id: 1001,
    pid: 10,
  },
  {
    name: 'serviceReviewPersonnel',
    path: '/service/review/personnel',
    title: '人员综合审核',
    requiresAuth: true,
    icon: 'icon-park-outline:people',
    menuType: 'page',
    componentPath: '/service/review/personnel/index.vue',
    id: 10011,
    pid: 1001,
  },
  {
    name: 'userContent',
    path: '/user-content',
    title: '用户&内容',
    requiresAuth: true,
    icon: 'icon-park-outline:user',
    menuType: 'dir',
    componentPath: null,
    id: 11,
    pid: null,
  },
  {
    name: 'contentCenter',
    path: '/user-content/content-center',
    title: '内容中心',
    requiresAuth: true,
    icon: 'icon-park-outline:file-text',
    menuType: 'dir',
    componentPath: null,
    id: 1101,
    pid: 11,
  },
  {
    name: 'contentCenterIndex',
    path: '/user-content/content-center/index',
    title: '内容中心',
    requiresAuth: true,
    icon: 'icon-park-outline:file-text',
    menuType: 'page',
    componentPath: '/user-content/content-center/index.vue',
    id: 11011,
    pid: 1101,
  },
]
src/views/cruise/patrol-points/index.vue
@@ -42,7 +42,7 @@
</script>
<template>
  <div class="relative h-full">
  <div class=" h-full">
    <n-card class="w-full" style="border-radius: 6px">
      <div class="mb-6 flex items-center">
        <div class="mr-2 bg-blue-500" style="width: 3px; height: 14px"></div>
@@ -98,7 +98,7 @@
        </div>
    </n-card>
    <div class="absolute bottom-0 left-0 right-0 flex justify-end gap-3 bg-white px-6 py-4">
    <div class="absolute bottom-0 left-0 right-0 flex justify-end gap-3 bg-white px-16px py-24px">
      <n-button style="width: 84px; height: 32px">取消</n-button>
      <n-button type="primary" style="width: 84px; height: 32px">保存</n-button>
    </div>
src/views/operations/attendance/plan/components/BatchSetModal.vue
New file
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
interface ShiftOption {
  name: string
  checked: boolean
  dateRange: number[] | null
}
interface FormModel {
  team: string | null
  shifts: ShiftOption[]
}
const props = defineProps<{
  show: boolean
}>()
const emit = defineEmits<{
  'update:show': [value: boolean]
  confirm: [data: FormModel]
}>()
const show = computed({
  get: () => props.show,
  set: (value) => emit('update:show', value)
})
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  team: null,
  shifts: [
    { name: '早', checked: false, dateRange: null },
    { name: '中', checked: false, dateRange: null },
    { name: '晚', checked: false, dateRange: null },
    { name: '全天', checked: false, dateRange: null }
  ]
})
// 生成1-31的日期选项
const dateOptions = Array.from({ length: 31 }, (_, i) => ({
  label: `${i + 1}`,
  value: i + 1
}))
const rules: FormRules = {
  team: [
    { required: true, message: '请选择队伍', trigger: 'change' }
  ]
}
// 模拟队伍数据
const teamOptions = [
  { label: '广州白云一队', value: '广州白云一队' },
  { label: '广州白云二队', value: '广州白云二队' },
  { label: '广州南沙三队', value: '广州南沙三队' }
]
const handleCancel = () => {
  show.value = false
  resetForm()
}
const handleConfirm = async () => {
  try {
    await formRef.value?.validate()
    emit('confirm', formModel.value)
    show.value = false
    resetForm()
  } catch (error) {
    console.log('验证失败', error)
  }
}
const resetForm = () => {
  formModel.value = {
    team: null,
    shifts: [
      { name: '早', checked: false, dateRange: null },
      { name: '中', checked: false, dateRange: null },
      { name: '晚', checked: false, dateRange: null },
      { name: '全天', checked: false, dateRange: null }
    ]
  }
}
</script>
<template>
  <n-modal
    v-model:show="show"
    :mask-closable="false"
    preset="card"
    title="批量设置"
    class="w-500px"
    :segmented="{
      content: true,
      action: true,
    }">
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="top"
      require-mark-placement="left"
      show-require-mark
    >
      <n-form-item label="队伍" path="team">
        <n-select
          v-model:value="formModel.team"
          :options="teamOptions"
          placeholder="选择"
        />
      </n-form-item>
      <n-form-item label="班次" path="shifts">
        <div class="w-full flex flex-col gap-3">
          <div
            v-for="shift in formModel.shifts"
            :key="shift.name"
            class="flex items-center gap-3"
          >
            <n-checkbox v-model:checked="shift.checked" style="width: 60px;">
              {{ shift.name }}
            </n-checkbox>
            <span class="text-gray-600">时间:</span>
            <n-select
              v-model:value="shift.dateRange"
              :disabled="!shift.checked"
              :options="dateOptions"
              placeholder="选择"
              multiple
              style="flex: 1"
            />
          </div>
        </div>
      </n-form-item>
    </n-form>
    <template #action>
      <n-space justify="end">
        <n-button @click="handleCancel">取消</n-button>
        <n-button type="primary" @click="handleConfirm">保存</n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped></style>
src/views/operations/attendance/plan/components/CopyScheduleModal.vue
New file
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInst, FormRules } from 'naive-ui'
const router = useRouter()
interface FormModel {
  month: number | null
  role: string | null
}
const props = defineProps<{
  show: boolean
}>()
const emit = defineEmits<{
  'update:show': [value: boolean]
  confirm: [data: FormModel]
}>()
const show = computed({
  get: () => props.show,
  set: (value) => emit('update:show', value)
})
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  month: null,
  role: null
})
const rules: FormRules = {
  month: [
    { required: true, type: 'number', message: '请选择排班月份', trigger: 'change' }
  ],
  role: [
    { required: true, message: '请选择角色', trigger: 'change' }
  ]
}
// 模拟角色数据
const roleOptions = [
  { label: '网格员', value: '网格员' },
  { label: '管理员', value: '管理员' },
  { label: '巡查员', value: '巡查员' }
]
const handleCancel = () => {
  show.value = false
  resetForm()
}
const handleConfirm = async () => {
  try {
    await formRef.value?.validate()
    if (formModel.value.month && formModel.value.role) {
      emit('confirm', formModel.value)
      show.value = false
      // 跳转到复制页面,携带月份和角色参数
      router.push({
        path: '/operations/attendance/plan/copy',
        query: {
          month: String(formModel.value.month),
          role: formModel.value.role
        }
      })
      resetForm()
    }
  } catch (error) {
    console.log('验证失败', error)
  }
}
const resetForm = () => {
  formModel.value = {
    month: null,
    role: null
  }
}
</script>
<template>
  <n-modal
    v-model:show="show"
    :mask-closable="false"
    preset="card"
    title="复制排班"
    class="w-500px"
    :segmented="{
      content: true,
      action: true,
    }">
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="top"
      require-mark-placement="left"
      show-require-mark
    >
      <n-form-item label="排班月份" path="month">
        <n-date-picker
          v-model:value="formModel.month"
          type="month"
          placeholder="请选择月份"
          style="width: 100%"
          format="yyyy-MM"
        />
      </n-form-item>
      <n-form-item label="角色" path="role">
        <n-select
          v-model:value="formModel.role"
          :options="roleOptions"
          placeholder="请选择角色"
        />
      </n-form-item>
    </n-form>
    <template #action>
      <n-space justify="end">
        <n-button @click="handleCancel">取消</n-button>
        <n-button type="primary" @click="handleConfirm">排班</n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped></style>
src/views/operations/attendance/plan/components/EditMonthModal.vue
@@ -57,12 +57,12 @@
    if (formModel.value.month) {
      emit('confirm', formModel.value.month)
      show.value = false
      resetForm()
      // 跳转到编辑页面,携带月份参数
      router.push({
        path: '/operations/attendance/plan/edit',
        query: { month: formModel.value.month }
        query: { month: String(formModel.value.month) }
      })
      resetForm()
    }
  } catch (error) {
    console.log('验证失败', error)
src/views/operations/attendance/plan/copy.vue
New file
@@ -0,0 +1,467 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
interface ShiftTag {
  name: string
  time: string
  color: string
  bgColor: string
}
interface TeamSchedule {
  team: string
  schedules: ShiftTag[][]
}
const route = useRoute()
const currentDate = ref('2024/12/01')
const selectedMonth = ref<number>(new Date(2024, 11, 1).valueOf())
const selectedRole = ref<string>('')
const shiftTags = ref<ShiftTag[]>([
  { name: '全班', time: '08:00-16:00', color: '#0068FF', bgColor: '#E6F4FF' },
  { name: '早班', time: '08:00-20:00', color: '#16A60C', bgColor: '#DCF5DD' },
  { name: '中班', time: '16:00-24:00', color: '#FF9000', bgColor: '#FFF8E6' },
  { name: '晚班', time: '19:00-24:00', color: '#764DFF', bgColor: '#F6F0FF' }
])
// 生成日期列表
const generateDates = (year: number, month: number) => {
  const dates = []
  const daysInMonth = new Date(year, month + 1, 0).getDate()
  for (let i = 1; i <= daysInMonth; i++) {
    const date = new Date(year, month, i)
    const weekDay = ['日', '一', '二', '三', '四', '五', '六'][date.getDay()]
    dates.push({
      month: String(month + 1),
      day: i,
      weekDay: weekDay
    })
  }
  return dates
}
const dates = ref(generateDates(2024, 11))
const teamSchedules = ref<TeamSchedule[]>([
  { team: '广州白云一队', schedules: Array(31).fill(null).map(() => []) },
  { team: '广州白云二队', schedules: Array(31).fill(null).map(() => []) },
  { team: '广州南沙三队', schedules: Array(31).fill(null).map(() => []) },
  { team: '广州白云一队', schedules: Array(31).fill(null).map(() => []) },
  { team: '广州白云一队', schedules: Array(31).fill(null).map(() => []) },
  { team: '广州白云一队', schedules: Array(31).fill(null).map(() => []) },
  { team: '广州白云一队', schedules: Array(31).fill(null).map(() => []) },
  { team: '广州白云一队', schedules: Array(31).fill(null).map(() => []) },
  { team: '广州白云一队', schedules: Array(31).fill(null).map(() => []) }
])
// 初始化一些示例数据
teamSchedules.value[0].schedules[3] = [shiftTags.value[0]]
teamSchedules.value[1].schedules[1] = [shiftTags.value[1]]
const selectedCell = ref<{ teamIndex: number; dateIndex: number } | null>(null)
const selectedShifts = ref<ShiftTag[]>([])
const selectorPosition = ref({ top: 0, left: 0 })
const updateDates = () => {
  const date = new Date(selectedMonth.value)
  const year = date.getFullYear()
  const month = date.getMonth()
  currentDate.value = `${year}/${String(month + 1).padStart(2, '0')}/01`
  dates.value = generateDates(year, month)
  // 重新初始化排班数据
  const daysInMonth = new Date(year, month + 1, 0).getDate()
  teamSchedules.value.forEach(schedule => {
    schedule.schedules = Array(daysInMonth).fill(null).map(() => [])
  })
}
const getDisplayDate = () => {
  const date = new Date(selectedMonth.value)
  const year = date.getFullYear()
  const month = date.getMonth() + 1
  return `${year}年${month}月`
}
const handleSave = () => {
  console.log('保存')
}
const getShiftCount = (schedules: ShiftTag[][], shiftName: string) => {
  let count = 0
  schedules.forEach(shifts => {
    count += shifts.filter(s => s.name === shiftName).length
  })
  return count
}
const showScheduleModal = ref(false)
const handleCellClick = (teamIndex: number, dateIndex: number, event: MouseEvent) => {
  event.stopPropagation() // 阻止事件冒泡
  selectedCell.value = { teamIndex, dateIndex }
  const currentShifts = teamSchedules.value[teamIndex].schedules[dateIndex]
  selectedShifts.value = currentShifts ? [...currentShifts] : []
  // 计算盒子位置
  const target = event.currentTarget as HTMLElement
  const rect = target.getBoundingClientRect()
  const boxWidth = 240
  const windowWidth = window.innerWidth
  // 判断右侧是否有足够空间,如果没有则显示在左侧
  let left = rect.right + 2
  if (left + boxWidth > windowWidth) {
    left = rect.left - boxWidth - 10
  }
  selectorPosition.value = {
    top: rect.top,
    left: Math.max(10, left) // 确保不会超出左边界
  }
  nextTick(() => {
    showScheduleModal.value = true
  })
}
const handleShiftToggle = (shift: ShiftTag) => {
  // 单选模式:直接设置为选中的班次
  selectedShifts.value = [shift]
  // 立即更新到表格
  if (selectedCell.value) {
    teamSchedules.value[selectedCell.value.teamIndex].schedules[selectedCell.value.dateIndex] = [shift]
  }
  // 选择后直接关闭
  closeScheduleModal()
}
// 点击外部关闭选择盒子
const handleClickOutside = (event: MouseEvent) => {
  if (!showScheduleModal.value) return
  const target = event.target as HTMLElement
  const selectorBox = document.querySelector('.schedule-selector-box')
  if (selectorBox && !selectorBox.contains(target)) {
    closeScheduleModal()
  }
}
const closeScheduleModal = () => {
  showScheduleModal.value = false
  selectedCell.value = null
}
onMounted(() => {
  document.addEventListener('click', handleClickOutside)
  // 从路由参数读取月份
  const monthParam = route.query.month
  if (monthParam) {
    const monthValue = typeof monthParam === 'string' ? Number(monthParam) : Array.isArray(monthParam) ? Number(monthParam[0]) : monthParam
    if (monthValue && !isNaN(monthValue)) {
      selectedMonth.value = monthValue
      updateDates()
    }
  }
  // 从路由参数读取角色
  const roleParam = route.query.role
  if (roleParam) {
    selectedRole.value = typeof roleParam === 'string' ? roleParam : Array.isArray(roleParam) ? (roleParam[0] || '') : ''
  }
})
onUnmounted(() => {
  document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px; margin-top: 12px; overflow: hidden;"
      content-style="padding: 12px; height: 100%; display: flex; flex-direction: column;">
      <div class="flex items-center justify-between" style="margin-bottom: 12px;">
        <div class="flex items-center gap-15">
          <div class="text-lg font-bold">角色:{{ selectedRole }}</div>
          <div class="text-lg font-bold">日期:{{ getDisplayDate() }}</div>
        </div>
      </div>
      <div class="flex items-center justify-between">
        <div class="flex items-center">
          <div v-for="tag in shiftTags" :key="`${tag.name}-${tag.time}`" style="margin-right: 8px;"
            class="flex items-center gap-1">
            <span class="rounded text-xs" :style="{
              backgroundColor: tag.bgColor,
              color: tag.color,
              width: '113px',
              height: '28px',
              borderRadius: '4px',
              display: 'inline-flex',
              alignItems: 'center',
              justifyContent: 'center'
            }">
              {{ tag.name }} {{ tag.time }}
            </span>
          </div>
        </div>
        <div class="flex gap-2">
          <n-button type="primary" style="width: 84px; height: 32px" @click="handleSave">保存</n-button>
        </div>
      </div>
      <div class="schedule-table-container" style="overflow-x: auto; flex: 1;margin-top: 12px;">
        <table class="schedule-table">
          <thead>
            <tr>
              <th class="sticky-col">车队</th>
              <th v-for="(date, index) in dates" :key="index">
                <div>{{ date.month }}/{{ date.day }}</div>
                <div class="text-xs text-gray-500" style="margin-top: 4px;">{{ date.weekDay }}</div>
              </th>
              <th class="summary-col">排班小计</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(schedule, teamIndex) in teamSchedules" :key="teamIndex">
              <td class="sticky-col team-name">{{ schedule.team }}</td>
              <td v-for="(shifts, dateIndex) in schedule.schedules" :key="dateIndex" class="schedule-cell"
                :class="{ 'schedule-cell-selected': selectedCell && selectedCell.teamIndex === teamIndex && selectedCell.dateIndex === dateIndex }"
                @click="handleCellClick(teamIndex, dateIndex, $event)">
                <div class="flex flex-wrap gap-1">
                  <span v-for="(shift, shiftIndex) in shifts" :key="shiftIndex" class="whitespace-nowrap"
                    :style="{ backgroundColor: shift.bgColor, color: shift.color }">
                    {{ shift.name }}
                  </span>
                </div>
              </td>
              <td class="summary-cell">
                <div class="flex flex-wrap justify-center" style="gap: 2px;">
                  <span v-for="tag in shiftTags" :key="`${tag.name}-${tag.time}`" class="summary-tag"
                    :style="{ backgroundColor: tag.bgColor, color: tag.color }">
                    {{ tag.name }} {{ getShiftCount(schedule.schedules, tag.name) }}
                  </span>
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </n-card>
    <!-- 排班选择盒子 -->
    <div v-if="showScheduleModal" class="schedule-selector-box"
      :style="{ top: `${selectorPosition.top}px`, left: `${selectorPosition.left}px` }">
      <div class="schedule-selector-header">
        <span class="schedule-selector-title">排班选择</span>
        <n-icon size="20" class="cursor-pointer" @click="closeScheduleModal">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
            stroke-linecap="round" stroke-linejoin="round">
            <line x1="18" y1="6" x2="6" y2="18"></line>
            <line x1="6" y1="6" x2="18" y2="18"></line>
          </svg>
        </n-icon>
      </div>
      <div class="schedule-selector-content">
        <div v-for="shift in shiftTags" :key="`${shift.name}-${shift.time}`" class="shift-option"
          @click="handleShiftToggle(shift)">
          <n-radio :checked="selectedShifts.some(s => s.name === shift.name && s.time === shift.time)" />
          <span class="shift-tag" :style="{ backgroundColor: shift.bgColor, color: shift.color }">
            {{ shift.name }} {{ shift.time }}
          </span>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.schedule-table-container {
  position: relative;
}
.schedule-table {
  border-collapse: collapse;
  background: white;
  table-layout: fixed;
}
.schedule-table th,
.schedule-table td {
  border: 1px solid #e5e7eb;
  text-align: center;
  vertical-align: middle;
}
.schedule-table th {
  background: #F4F6F8;
  font-weight: 500;
  position: sticky;
  top: 0;
  z-index: 10;
  min-width: 52px;
  width: 52px;
  height: 62px;
  padding: 4px;
}
.schedule-table th.sticky-col {
  min-width: 148px;
  width: 148px;
  height: 62px;
}
.sticky-col {
  position: sticky;
  left: 0;
  background: white;
  z-index: 11;
  min-width: 148px;
  width: 148px;
}
.schedule-table th.sticky-col {
  z-index: 12;
  background: #F4F6F8;
}
.team-name {
  font-weight: 500;
  text-align: center;
  height: 40px;
  padding: 8px;
}
.schedule-cell {
  cursor: pointer;
  transition: background-color 0.2s;
  min-width: 52px;
  width: 52px;
  height: 40px;
  padding: 2px;
}
.schedule-cell:hover {
  background-color: #f9fafb;
}
.schedule-cell-selected {
  background-color: #E6F4FF !important;
  border: 2px solid #0068FF !important;
}
.schedule-cell .flex {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.schedule-cell span {
  width: 48px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  font-size: 12px;
  flex-shrink: 0;
}
.summary-col {
  min-width: 260px;
  width: 260px;
  position: sticky;
  right: 0;
  background: #f9fafb;
  z-index: 10;
}
.summary-cell {
  min-width: 260px;
  width: 260px;
  padding: 4px;
  position: sticky;
  right: 0;
  background: white;
  z-index: 9;
}
.summary-tag {
  width: 61px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  font-size: 12px;
  flex-shrink: 0;
}
.schedule-selector-box {
  position: fixed;
  background: white;
  width: 240px;
  height: 224px;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  overflow: hidden;
  z-index: 1000;
  display: flex;
  flex-direction: column;
}
.schedule-selector-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px;
  border-bottom: 1px solid #e5e7eb;
}
.schedule-selector-title {
  font-size: 16px;
  font-weight: 500;
  color: #333;
}
.schedule-selector-content {
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  flex: 1;
}
.shift-option {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  padding: 0;
  border-radius: 4px;
  transition: background-color 0.2s;
}
.shift-option:hover {
  background-color: #f9fafb;
}
.shift-tag {
  width: 113px;
  height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  font-size: 12px;
}
</style>
src/views/operations/attendance/plan/edit.vue
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import BatchSetModal from './components/BatchSetModal.vue'
interface ShiftTag {
  name: string
@@ -12,6 +14,8 @@
  team: string
  schedules: ShiftTag[][]
}
const route = useRoute()
const currentDate = ref('2024/12/01')
const selectedMonth = ref<number>(new Date(2024, 11, 1).valueOf())
@@ -75,8 +79,15 @@
  })
}
const getDisplayDate = () => {
  const date = new Date(selectedMonth.value)
  const year = date.getFullYear()
  const month = date.getMonth() + 1
  return `${year}年${month}月`
}
const handleBatchEdit = () => {
  console.log('批量设置')
  showBatchSetModal.value = true
}
const handleSave = () => {
@@ -92,6 +103,7 @@
}
const showScheduleModal = ref(false)
const showBatchSetModal = ref(false)
const handleCellClick = (teamIndex: number, dateIndex: number, event: MouseEvent) => {
  event.stopPropagation() // 阻止事件冒泡
@@ -154,6 +166,16 @@
onMounted(() => {
  document.addEventListener('click', handleClickOutside)
  // 从路由参数读取月份
  const monthParam = route.query.month
  if (monthParam) {
    const monthValue = typeof monthParam === 'string' ? Number(monthParam) : Array.isArray(monthParam) ? Number(monthParam[0]) : monthParam
    if (monthValue && !isNaN(monthValue)) {
      selectedMonth.value = monthValue
      updateDates()
    }
  }
})
onUnmounted(() => {
@@ -166,7 +188,7 @@
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px; margin-top: 12px; overflow: hidden;" content-style="padding: 12px; height: 100%; display: flex; flex-direction: column;">
        <div class="flex items-center justify-between" style="margin-bottom: 12px;">
        <div class="text-lg font-bold">日期:2020年5月</div>
        <div class="text-lg font-bold">日期:{{ getDisplayDate() }}</div>
      </div>
      <div class="flex items-center justify-between">
@@ -283,7 +305,9 @@
      </div>
    </div>
    <BatchSetModal
      v-model:show="showBatchSetModal"
    />
  </div>
</template>
src/views/operations/attendance/plan/index.vue
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import EditMonthModal from './components/EditMonthModal.vue'
import CopyScheduleModal from './components/CopyScheduleModal.vue'
interface ShiftTag {
  name: string
@@ -83,7 +84,7 @@
}
const handleCopy = () => {
  console.log('复制')
  showCopyModal.value = true
}
const handleDownload = () => {
@@ -114,6 +115,7 @@
const showScheduleModal = ref(false)
const showEditModal = ref(false)
const showCopyModal = ref(false)
const handleCellClick = (teamIndex: number, dateIndex: number) => {
  selectedCell.value = { teamIndex, dateIndex }
@@ -289,6 +291,10 @@
      :current-month="selectedMonth"
      @confirm="handleConfirmEdit" 
    />
    <CopyScheduleModal
      v-model:show="showCopyModal"
    />
  </div>
</template>
src/views/operations/message/index.vue
New file
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NCheckbox } from 'naive-ui'
interface MessageItem {
  id: number
  type: string
  pushMethod: boolean
  smsMethod: boolean
  updateTime: string
}
const tableData = ref<MessageItem[]>([
  { id: 1, type: '新任务通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 2, type: '任务状态通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 3, type: '排班变动通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 4, type: '公告通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 5, type: '新事故通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 6, type: '事故状态通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' }
])
const columns: DataTableColumns<MessageItem> = [
  {
    title: '序号',
    key: 'id',
    width: 80,
    align: 'center'
  },
  {
    title: '通知类型',
    key: 'type',
    align: 'center'
  },
  {
    title: '通知方式',
    key: 'method',
    align: 'center',
    render: (row) => {
      return h('div', { class: 'flex items-center justify-center gap-4' }, [
        h(NCheckbox, {
          checked: row.pushMethod,
          onUpdateChecked: (value: boolean) => {
            row.pushMethod = value
          }
        }, { default: () => 'PUSH推送' }),
        h(NCheckbox, {
          checked: row.smsMethod,
          onUpdateChecked: (value: boolean) => {
            row.smsMethod = value
          }
        }, { default: () => '短信通知' })
      ])
    }
  },
  {
    title: '更新时间',
    key: 'updateTime',
    align: 'center'
  }
]
const handleSave = () => {
  console.log('保存', tableData.value)
}
</script>
<template>
  <div class="h-full flex-col">
    <n-card style="border-radius: 6px; overflow: hidden;" content-style="padding: 12px; height: 100%; display: flex; flex-direction: column;">
      <div style="flex: 1; overflow: auto;">
        <n-data-table
          :columns="columns"
          :data="tableData"
          :bordered="true"
          :single-line="false"
        />
      </div>
    </n-card>
    <div class="flex justify-end absolute bottom-0 left-0 right-0" style="background-color: white; padding: 16px 24px; border-radius: 6px;">
      <n-button type="primary" style="width: 84px; height: 32px" @click="handleSave">保存</n-button>
    </div>
  </div>
</template>
<style scoped>
:deep(.n-data-table-th) {
  background-color: #F4F6F8;
}
</style>
src/views/service/review/personnel/components/ReviewModal.vue
New file
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
interface FormModel {
  result: 'pass' | 'reject'
  remark: string
}
const props = defineProps<{
  show: boolean
}>()
const emit = defineEmits<{
  'update:show': [value: boolean]
  confirm: [data: FormModel]
}>()
const show = computed({
  get: () => props.show,
  set: (value) => emit('update:show', value)
})
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  result: 'pass',
  remark: ''
})
const rules: FormRules = {
  result: [
    { required: true, message: '请选择审核结果', trigger: 'change' }
  ]
}
const handleCancel = () => {
  show.value = false
  resetForm()
}
const handleConfirm = async () => {
  try {
    await formRef.value?.validate()
    emit('confirm', formModel.value)
    show.value = false
    resetForm()
  } catch (error) {
    console.log('验证失败', error)
  }
}
const resetForm = () => {
  formModel.value = {
    result: 'pass',
    remark: ''
  }
}
</script>
<template>
  <n-modal
    v-model:show="show"
    :mask-closable="false"
    preset="card"
    title="审核"
    class="w-600px"
    :segmented="{
      content: true,
      action: true,
    }">
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="left"
      label-width="80"
      require-mark-placement="left"
    >
      <n-form-item label="审核" path="result" required>
        <n-radio-group v-model:value="formModel.result">
          <n-radio value="pass">通过</n-radio>
          <n-radio value="reject">不通过</n-radio>
        </n-radio-group>
      </n-form-item>
      <n-form-item label="备注" path="remark">
        <n-input
          v-model:value="formModel.remark"
          type="textarea"
          placeholder="请输入"
          :rows="4"
        />
      </n-form-item>
    </n-form>
    <template #action>
      <n-space justify="end">
        <n-button @click="handleCancel">取消</n-button>
        <n-button type="primary" @click="handleConfirm">保存</n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped></style>
src/views/service/review/personnel/index.vue
New file
@@ -0,0 +1,452 @@
<script setup lang="ts">
import { ref, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NTag, NAvatar, NImage } from 'naive-ui'
import ReviewModal from './components/ReviewModal.vue'
interface PersonnelItem {
  id: number
  name: string
  account: string
  phone: string
  position: string
  status: string
  idCardFront: string
  idCardBack: string
  driverLicense: string
  qualificationCert: string
  noCriminalRecord: string
}
const searchForm = ref({
  name: '',
  account: '',
  position: null,
  status: null
})
const positionOptions = [
  { label: '信息员', value: '信息员' },
  { label: '网格员', value: '网格员' },
  { label: '管理员', value: '管理员' }
]
const statusOptions = [
  { label: '待提交', value: 'pending' },
  { label: '待审核', value: 'review' },
  { label: '已通过', value: 'approved' },
  { label: '已驳回', value: 'rejected' }
]
const tableData = ref<PersonnelItem[]>([
  {
    id: 1,
    name: '王美丽',
    account: '3948716',
    phone: '14725836905',
    position: '信息员',
    status: '网格员',
    idCardFront: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
    idCardBack: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
    driverLicense: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
    qualificationCert: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
    noCriminalRecord: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg'
  },
  {
    id: 2,
    name: '王美丽',
    account: '3948716',
    phone: '14725836905',
    position: '待ID1',
    status: '',
    idCardFront: '',
    idCardBack: '',
    driverLicense: '',
    qualificationCert: '',
    noCriminalRecord: ''
  },
  {
    id: 3,
    name: '王美丽',
    account: '3948716',
    phone: '14725836905',
    position: '待ID1',
    status: '',
    idCardFront: '',
    idCardBack: '',
    driverLicense: '',
    qualificationCert: '',
    noCriminalRecord: ''
  },
  {
    id: 4,
    name: '王美丽',
    account: '3948716',
    phone: '14725836905',
    position: '待ID1',
    status: '',
    idCardFront: '',
    idCardBack: '',
    driverLicense: '',
    qualificationCert: '',
    noCriminalRecord: ''
  },
  {
    id: 5,
    name: '王美丽',
    account: '3948716',
    phone: '14725836905',
    position: '待ID1',
    status: '',
    idCardFront: '',
    idCardBack: '',
    driverLicense: '',
    qualificationCert: '',
    noCriminalRecord: ''
  },
  {
    id: 6,
    name: '王美丽',
    account: '3948716',
    phone: '14725836905',
    position: '待ID1',
    status: '',
    idCardFront: '',
    idCardBack: '',
    driverLicense: '',
    qualificationCert: '',
    noCriminalRecord: ''
  },
  {
    id: 7,
    name: '王美丽',
    account: '3948716',
    phone: '14725836905',
    position: '待ID1',
    status: '',
    idCardFront: '',
    idCardBack: '',
    driverLicense: '',
    qualificationCert: '',
    noCriminalRecord: ''
  }
])
const columns: DataTableColumns<PersonnelItem> = [
  {
    type: 'selection'
  },
  {
    title: '序号',
    key: 'id',
    width: 80,
    align: 'center'
  },
  {
    title: '姓名',
    key: 'name',
    width: 120,
    align: 'center',
    render: (row) => {
      return h('div', { class: 'flex items-center justify-center gap-2' }, [
        h(NAvatar, {
          round: true,
          size: 32,
          style: { backgroundColor: '#e0e0e0' }
        }, { default: () => row.name.charAt(0) }),
        h('span', row.name)
      ])
    }
  },
  {
    title: '编号',
    key: 'account',
    width: 120,
    align: 'center'
  },
  {
    title: '联系电话',
    key: 'phone',
    width: 140,
    align: 'center'
  },
  {
    title: '岗位',
    key: 'position',
    width: 150,
    align: 'center',
    render: (row) => {
      if (row.position === '信息员') {
        return h(NTag, { type: 'info', size: 'small' }, { default: () => row.position })
      }
      return h(NTag, { type: 'default', size: 'small' }, { default: () => row.position })
    }
  },
  {
    title: '身份证',
    key: 'idCard',
    width: 150,
    align: 'center',
    render: (row) => {
      if (row.idCardFront && row.idCardBack) {
        return h('div', { class: 'flex gap-1 justify-center' }, [
          h(NImage, {
            width: 60,
            height: 40,
            src: row.idCardFront,
            objectFit: 'cover',
            style: { borderRadius: '4px' },
            previewSrc: row.idCardFront
          }),
          h(NImage, {
            width: 60,
            height: 40,
            src: row.idCardBack,
            objectFit: 'cover',
            style: { borderRadius: '4px' },
            previewSrc: row.idCardBack
          })
        ])
      }
      return h('div', {
        class: 'flex gap-1 justify-center'
      }, [
        h('div', {
          style: {
            width: '60px',
            height: '40px',
            backgroundColor: '#f0f0f0',
            borderRadius: '4px',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center'
          }
        }, '📷'),
        h('div', {
          style: {
            width: '60px',
            height: '40px',
            backgroundColor: '#f0f0f0',
            borderRadius: '4px',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center'
          }
        }, '📷')
      ])
    }
  },
  {
    title: '驾驶证',
    key: 'driverLicense',
    width: 100,
    align: 'center',
    render: (row) => {
      if (row.driverLicense) {
        return h(NImage, {
          width: 60,
          height: 40,
          src: row.driverLicense,
          objectFit: 'cover',
          style: { borderRadius: '4px' },
          previewSrc: row.driverLicense
        })
      }
      return h('div', {
        style: {
          width: '60px',
          height: '40px',
          backgroundColor: '#f0f0f0',
          borderRadius: '4px',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          margin: '0 auto'
        }
      }, '📷')
    }
  },
  {
    title: '无犯罪记录证明',
    key: 'noCriminalRecord',
    width: 140,
    align: 'center',
    render: (row) => {
      if (row.noCriminalRecord) {
        return h(NImage, {
          width: 60,
          height: 40,
          src: row.noCriminalRecord,
          objectFit: 'cover',
          style: { borderRadius: '4px' },
          previewSrc: row.noCriminalRecord
        })
      }
      return h('div', {
        style: {
          width: '60px',
          height: '40px',
          backgroundColor: '#f0f0f0',
          borderRadius: '4px',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          margin: '0 auto'
        }
      }, '📷')
    }
  },
  {
    title: '操作',
    key: 'actions',
    width: 150,
    align: 'center',
    fixed: 'right',
    render: (row) => {
      return h('div', { class: 'flex items-center justify-center gap-2' }, [
        h(NButton, {
          text: true,
          type: 'primary',
          onClick: () => handleApprove(row.id)
        }, { default: () => '审核' }),
        h(NButton, {
          text: true,
          type: 'error',
          onClick: () => handleDelete(row.id)
        }, { default: () => '删除' })
      ])
    }
  }
]
const pagination = ref({
  page: 1,
  pageSize: 10,
  itemCount: 800,
  showSizePicker: true,
  pageSizes: [10, 20, 50, 100]
})
const showReviewModal = ref(false)
const selectedIds = ref<number[]>([])
const handleSearch = () => {
  console.log('搜索', searchForm.value)
}
const handleReset = () => {
  searchForm.value = {
    name: '',
    account: '',
    position: null,
    status: null
  }
}
const handleBatchReview = () => {
  if (selectedIds.value.length === 0) {
    window.$message?.warning('请先选择要审核的人员')
    return
  }
  showReviewModal.value = true
}
const handleApprove = (id: number) => {
  selectedIds.value = [id]
  showReviewModal.value = true
}
const handleDelete = (id: number) => {
  window.$dialog?.warning({
    title: '确认删除',
    content: '确定要删除该人员吗?',
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: () => {
      console.log('删除', id)
      window.$message?.success('删除成功')
    }
  })
}
const handleConfirmReview = (data: any) => {
  console.log('审核结果', data, '选中的ID', selectedIds.value)
  window.$message?.success('审核成功')
  selectedIds.value = []
}
const handleCheck = (rowKeys: Array<string | number>) => {
  selectedIds.value = rowKeys as number[]
}
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form :model="searchForm" label-placement="left" inline :show-feedback="false">
        <div class="flex justify-between items-center w-full">
          <div class="flex gap-4">
            <n-form-item label="员工姓名">
              <n-input v-model:value="searchForm.name" placeholder="请输入" clearable style="width: 200px" />
            </n-form-item>
            <n-form-item label="联系电话">
              <n-input v-model:value="searchForm.account" placeholder="请输入" clearable style="width: 200px" />
            </n-form-item>
            <n-form-item label="岗位">
              <n-select
                v-model:value="searchForm.position"
                placeholder="请选择"
                clearable
                style="width: 200px"
                :options="positionOptions"
              />
            </n-form-item>
            <n-form-item label="审核状态">
              <n-select
                v-model:value="searchForm.status"
                placeholder="请选择"
                clearable
                style="width: 200px"
                :options="statusOptions"
              />
            </n-form-item>
          </div>
          <div class="flex gap-2">
            <n-button style="width: 84px; height: 32px" @click="handleReset">重置</n-button>
            <n-button type="primary" style="width: 84px; height: 32px" @click="handleSearch">查询</n-button>
          </div>
        </div>
      </n-form>
    </n-card>
    <n-card style="border-radius: 6px; margin-top: 12px;" content-style="padding: 12px">
      <div class="mb-4 flex justify-end gap-2">
        <n-button style="width: 84px; height: 32px;color: #0068FF; background-color: #E6F4FF; border: 1px solid #7ABDFF;" @click="handleBatchReview">批量审核</n-button>
      </div>
      <n-data-table
        :columns="columns"
        :data="tableData"
        :pagination="{
          page: pagination.page,
          pageSize: pagination.pageSize,
          itemCount: pagination.itemCount,
          showSizePicker: true,
          pageSizes: pagination.pageSizes,
          prefix: ({ itemCount }) => `共 ${itemCount || 0} 条`
        }"
        :scroll-x="1400"
        :row-key="(row: PersonnelItem) => row.id"
        @update:checked-row-keys="handleCheck"
      />
    </n-card>
    <ReviewModal
      v-model:show="showReviewModal"
      @confirm="handleConfirmReview"
    />
  </div>
</template>
<style scoped>
</style>
src/views/user-content/content-center/components/ContentForm.vue
New file
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
interface Step {
  id: number
  title: string
  content: string
  images: string[]
}
interface FormModel {
  images: string[]
  steps: Step[]
}
const contentType = ref('image')
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  images: [],
  steps: [
    { id: 1, title: '', content: '', images: [] }
  ]
})
const rules = computed<FormRules>(() => {
  if (contentType.value === 'image') {
    return {
      images: [
        { required: true, type: 'array', min: 1, message: '请上传图片', trigger: 'change' }
      ]
    }
  } else {
    const stepRules: FormRules = {}
    formModel.value.steps.forEach((_, index) => {
      stepRules[`steps[${index}].title`] = [
        { required: true, message: '请输入标题', trigger: 'blur' }
      ]
      stepRules[`steps[${index}].images`] = [
        { required: true, type: 'array', min: 1, message: '请上传图片', trigger: 'change' }
      ]
    })
    return stepRules
  }
})
const handleUpload = (options: any) => {
  console.log('上传文件', options)
}
const addStep = () => {
  const newId = formModel.value.steps.length + 1
  formModel.value.steps.push({ id: newId, title: '', content: '', images: [] })
}
const deleteStep = (index: number) => {
  if (formModel.value.steps.length > 1) {
    formModel.value.steps.splice(index, 1)
  }
}
const validate = async () => {
  return await formRef.value?.validate()
}
defineExpose({
  validate,
  getData: () => ({
    contentType: contentType.value,
    images: formModel.value.images,
    steps: formModel.value.steps
  })
})
</script>
<template>
  <div class="flex gap-4">
    <!-- 左侧内容区 -->
    <div style="flex: 1; max-width: 800px;">
      <n-tabs
        v-model:value="contentType"
        type="segment"
        size="small"
        :theme-overrides="{
          tabColorSegment: '#0068FF',
          tabTextColorActiveSegment: '#ffffff',
          tabTextColorSegment: '#333333'
        }"
        class="mb-4"
        style="width: 200px;"
      >
        <n-tab-pane name="image" tab="整图" />
        <n-tab-pane name="steps" tab="分步骤" />
      </n-tabs>
      <n-form
        ref="formRef"
        :model="formModel"
        :rules="rules"
        label-placement="left"
        label-width="80"
        require-mark-placement="left"
      >
        <!-- 整图模式 -->
        <div v-if="contentType === 'image'">
          <n-form-item label="图片" path="images" required>
            <n-upload
              :max="10"
              list-type="image-card"
              :custom-request="handleUpload"
            >
              <div style="width: 100px; height: 100px; display: flex; align-items: center; justify-content: center; font-size: 32px; color: #999;">
                +
              </div>
            </n-upload>
          </n-form-item>
        </div>
        <!-- 分步骤模式 -->
        <div v-else>
          <n-button type="primary" @click="addStep" class="mb-4">添加步骤</n-button>
          <div v-for="(step, index) in formModel.steps" :key="step.id" class="mb-6">
            <div class="flex items-center gap-2 mb-4">
              <span>步骤{{ index + 1 }}:</span>
              <n-button text type="error" @click="deleteStep(index)">
                <template #icon>
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" style="width: 18px; height: 18px; color: #ff4d4f;">
                    <path d="M12 12h2v12h-2z" fill="currentColor"></path>
                    <path d="M18 12h2v12h-2z" fill="currentColor"></path>
                    <path d="M4 6v2h2v20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8h2V6zm4 22V8h16v20z" fill="currentColor"></path>
                    <path d="M12 2h8v2h-8z" fill="currentColor"></path>
                  </svg>
                </template>
              </n-button>
            </div>
            <n-form-item label="标题" :path="`steps[${index}].title`" required>
              <n-input v-model:value="step.title" placeholder="请输入" style="width: 300px;" />
            </n-form-item>
            <n-form-item label="内容" :path="`steps[${index}].content`">
              <n-input
                v-model:value="step.content"
                placeholder="请输入"
                style="width: 300px;"
              />
            </n-form-item>
            <n-form-item label="图片" :path="`steps[${index}].images`" required>
              <n-upload
                :max="10"
                list-type="image-card"
                :custom-request="handleUpload"
              >
                <div style="width: 100px; height: 100px; display: flex; align-items: center; justify-content: center; font-size: 32px; color: #999;">
                  +
                </div>
              </n-upload>
            </n-form-item>
          </div>
        </div>
      </n-form>
    </div>
    <!-- 右侧手机预览 -->
    <div style="width: 280px; flex-shrink: 0;">
      <div class="phone-preview">
        <img src="@/assets/service/phone.png" alt="手机预览" style="width: 100%; height: auto;" />
      </div>
    </div>
  </div>
</template>
<style scoped>
.phone-preview {
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>
src/views/user-content/content-center/components/FaqForm.vue
New file
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
interface FaqItem {
  id: number
  question: string
  answer: string
}
interface FormModel {
  items: FaqItem[]
}
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  items: [
    { id: 1, question: '', answer: '' },
    { id: 2, question: '', answer: '' },
    { id: 3, question: '', answer: '' }
  ]
})
const rules: FormRules = {}
const addRow = () => {
  const newId = formModel.value.items.length + 1
  formModel.value.items.push({ id: newId, question: '', answer: '' })
}
const deleteRow = (index: number) => {
  if (formModel.value.items.length > 1) {
    formModel.value.items.splice(index, 1)
  }
}
const validate = async () => {
  return await formRef.value?.validate()
}
defineExpose({
  validate,
  getData: () => formModel.value
})
</script>
<template>
  <div>
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
    >
      <n-table :bordered="true" :single-line="false">
        <thead>
          <tr>
            <th style="width: 80px; text-align: center;">序号</th>
            <th style="width: 250px;">常见问题</th>
            <th>说明</th>
            <th style="width: 100px; text-align: center;">操作</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(item, index) in formModel.items" :key="item.id">
            <td style="text-align: center;">{{ index + 1 }}</td>
            <td>
              <n-input
                v-model:value="item.question"
                placeholder="请输入"
                :bordered="false"
              />
            </td>
            <td>
              <n-input
                v-model:value="item.answer"
                type="textarea"
                placeholder="请输入"
                :bordered="false"
                :autosize="{ minRows: 3, maxRows: 6 }"
              />
            </td>
            <td style="text-align: center;">
              <n-button text type="primary" @click="deleteRow(index)">
                移除
              </n-button>
            </td>
          </tr>
        </tbody>
      </n-table>
    </n-form>
    <div class="mt-4">
      <n-button text type="primary" @click="addRow">
        + 添加行
      </n-button>
    </div>
  </div>
</template>
<style scoped>
:deep(.n-input__input-el) {
  padding: 8px;
}
:deep(.n-input__textarea-el) {
  padding: 8px;
}
</style>
src/views/user-content/content-center/components/SimpleRichTextForm.vue
New file
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
import RichTextEditor from '@/components/custom/Editor/RichTextEditor/index.vue'
interface FormModel {
  content: string
}
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  content: ''
})
const rules: FormRules = {
  content: [
    { required: true, message: '请输入内容', trigger: 'blur' }
  ]
}
const validate = async () => {
  return await formRef.value?.validate()
}
defineExpose({
  validate,
  getData: () => formModel.value
})
</script>
<template>
  <div>
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="left"
      label-width="80"
      require-mark-placement="left"
    >
      <n-form-item label="" path="content" required>
        <div class="rich-text-wrapper">
          <RichTextEditor v-model="formModel.content" />
        </div>
      </n-form-item>
    </n-form>
  </div>
</template>
<style scoped>
.rich-text-wrapper {
  width: 100%;
}
.rich-text-wrapper :deep(.ql-container) {
  min-height: 300px;
  font-size: 14px;
}
.rich-text-wrapper :deep(.ql-editor) {
  min-height: 300px;
}
</style>
src/views/user-content/content-center/components/VehiclePrepareForm.vue
New file
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
import RichTextEditor from '@/components/custom/Editor/RichTextEditor/index.vue'
interface FormModel {
  tirePressure: string
  lighting: string
  bodyClean: string
  cabinClean: string
  lostItems: string
}
const activeSubTab = ref('tirePressure')
const subTabs = [
  { key: 'tirePressure', label: '胎压说明' },
  { key: 'lighting', label: '灯光说明' },
  { key: 'bodyClean', label: '车身标识及清洁' },
  { key: 'cabinClean', label: '舱内清洁及消毒' },
  { key: 'lostItems', label: '遗失物品上报' }
]
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  tirePressure: '',
  lighting: '',
  bodyClean: '',
  cabinClean: '',
  lostItems: ''
})
const rules: FormRules = {
  tirePressure: [
    { required: true, message: '请输入胎压说明', trigger: 'blur' }
  ],
  lighting: [
    { required: true, message: '请输入灯光说明', trigger: 'blur' }
  ],
  bodyClean: [
    { required: true, message: '请输入车身标识及清洁说明', trigger: 'blur' }
  ],
  cabinClean: [
    { required: true, message: '请输入舱内清洁及消毒说明', trigger: 'blur' }
  ],
  lostItems: [
    { required: true, message: '请输入遗失物品上报说明', trigger: 'blur' }
  ]
}
const validate = async () => {
  return await formRef.value?.validate()
}
defineExpose({
  validate,
  getData: () => formModel.value
})
</script>
<template>
  <div>
    <n-tabs
      v-model:value="activeSubTab"
      type="line"
      size="small"
      :theme-overrides="{
        tabColorSegment: '#0068FF',
        tabTextColorActiveSegment: '#ffffff',
        tabTextColorSegment: '#333333'
      }"
      class="mb-4"
    >
      <n-tab-pane v-for="tab in subTabs" :key="tab.key" :name="tab.key" :tab="tab.label" />
    </n-tabs>
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="left"
      label-width="80"
      require-mark-placement="left"
    >
      <n-form-item label="" path="tirePressure" required v-show="activeSubTab === 'tirePressure'">
        <div class="rich-text-wrapper">
          <RichTextEditor v-model="formModel.tirePressure" />
        </div>
      </n-form-item>
      <n-form-item label="" path="lighting" required v-show="activeSubTab === 'lighting'">
        <div class="rich-text-wrapper">
          <RichTextEditor v-model="formModel.lighting" />
        </div>
      </n-form-item>
      <n-form-item label="" path="bodyClean" required v-show="activeSubTab === 'bodyClean'">
        <div class="rich-text-wrapper">
          <RichTextEditor v-model="formModel.bodyClean" />
        </div>
      </n-form-item>
      <n-form-item label="" path="cabinClean" required v-show="activeSubTab === 'cabinClean'">
        <div class="rich-text-wrapper">
          <RichTextEditor v-model="formModel.cabinClean" />
        </div>
      </n-form-item>
      <n-form-item label="" path="lostItems" required v-show="activeSubTab === 'lostItems'">
        <div class="rich-text-wrapper">
          <RichTextEditor v-model="formModel.lostItems" />
        </div>
      </n-form-item>
    </n-form>
  </div>
</template>
<style scoped>
.rich-text-wrapper {
  width: 100%;
}
.rich-text-wrapper :deep(.ql-container) {
  min-height: 300px;
  font-size: 14px;
}
.rich-text-wrapper :deep(.ql-editor) {
  min-height: 300px;
}
</style>
src/views/user-content/content-center/index.vue
New file
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref } from 'vue'
import ContentForm from './components/ContentForm.vue'
import VehiclePrepareForm from './components/VehiclePrepareForm.vue'
import SimpleRichTextForm from './components/SimpleRichTextForm.vue'
import FaqForm from './components/FaqForm.vue'
const activeTab = ref('guide')
const tabs = [
  { key: 'guide', label: '乘车指南' },
  { key: 'prepare', label: '车辆整备' },
  { key: 'charge', label: '车辆充电' },
  { key: 'inspect', label: '车辆检测' },
  { key: 'notice', label: '乘车须知' },
  { key: 'faq', label: '常见问题' }
]
const contentFormRefs = ref<Record<string, any>>({})
const handleSave = async () => {
  try {
    const currentForm = contentFormRefs.value[activeTab.value]
    if (currentForm) {
      await currentForm.validate()
      const data = currentForm.getData()
      console.log('保存', activeTab.value, data)
    }
  } catch (error) {
    console.log('验证失败', error)
  }
}
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-tabs
        v-model:value="activeTab"
        type="segment"
        :theme-overrides="{
          tabColorSegment: '#0068FF',
          tabTextColorActiveSegment: '#ffffff',
          tabTextColorSegment: '#333333'
        }"
      >
        <n-tab-pane v-for="tab in tabs" :key="tab.key" :name="tab.key" :tab="tab.label">
          <VehiclePrepareForm
            v-if="tab.key === 'prepare'"
            :ref="(el) => { if (el) contentFormRefs[tab.key] = el }"
          />
          <SimpleRichTextForm
            v-else-if="tab.key === 'charge' || tab.key === 'inspect' || tab.key === 'notice'"
            :ref="(el) => { if (el) contentFormRefs[tab.key] = el }"
          />
          <FaqForm
            v-else-if="tab.key === 'faq'"
            :ref="(el) => { if (el) contentFormRefs[tab.key] = el }"
          />
          <ContentForm
            v-else
            :ref="(el) => { if (el) contentFormRefs[tab.key] = el }"
          />
        </n-tab-pane>
      </n-tabs>
    </n-card>
    <div class="flex justify-end absolute bottom-0 left-0 right-0" style="background-color: white; padding: 16px 24px; border-radius: 6px; margin-top: 12px;">
      <n-button type="primary" style="width: 84px; height: 32px" @click="handleSave">保存</n-button>
    </div>
  </div>
</template>
<style scoped>
:deep(.n-tabs-nav--segment .n-tabs-tab--active) {
  background-color: #0068FF !important;
}
</style>