Przeglądaj źródła

feat: #13 前端公共组件开发

绩效管理复用组件库,整合项目卡片/产值表单/审核标签/协作表格/绩效图表/部门选择器

- ProjectCard: 项目信息卡片,产值双列展示,支持click+actions插槽
- OutputForm: 产值填报表单,进度滑块+产值输入+实时校验
- ReviewStatusTag: 审核状态标签,5种状态自动颜色映射
- CooperationTable: 协作关系表格,牵头/协作/汇总产值展示
- PerformanceChart: ECharts通用封装,支持bar/line/pie 3种类型
- DeptSelector: 部门选择器,tree-select/select双模式,默认6部门

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
shenzx 1 miesiąc temu
rodzic
commit
eabde7abd6

+ 57 - 0
src/components/performance/CooperationTable.vue

@@ -0,0 +1,57 @@
+<template>
+  <div class="cooperation-table">
+    <el-table :data="data" border v-loading="loading" class="cooperation-table__inner">
+      <el-table-column label="序号" type="index" width="60" align="center" />
+      <el-table-column label="项目名称" align="center" prop="projectName" :show-overflow-tooltip="true" />
+      <el-table-column label="牵头部门" align="center" prop="leadDeptName">
+        <template #default="scope">
+          <el-tag type="" effect="plain">{{ scope.row.leadDeptName }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="协作部门" align="center" prop="coopDeptName">
+        <template #default="scope">
+          <el-tag type="warning" effect="plain">{{ scope.row.coopDeptName }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="牵头产值(万元)" align="center" prop="leadOutput" />
+      <el-table-column label="协作产值(万元)" align="center" prop="coopOutput" />
+      <el-table-column label="当月总产值(万元)" align="center">
+        <template #default="scope">
+          <span style="font-weight: bold; color: #409EFF">
+            {{ ((scope.row.leadOutput || 0) + (scope.row.coopOutput || 0)).toFixed(4) }}
+          </span>
+        </template>
+      </el-table-column>
+      <el-table-column v-if="showActions" label="操作" align="center" width="180">
+        <template #default="scope">
+          <slot name="actions" :row="scope.row" />
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  data: {
+    type: Array,
+    default: () => []
+  },
+  loading: {
+    type: Boolean,
+    default: false
+  },
+  showActions: {
+    type: Boolean,
+    default: false
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.cooperation-table {
+  &__inner {
+    // 协作关系表格特定的样式微调
+  }
+}
+</style>

+ 131 - 0
src/components/performance/DeptSelector.vue

@@ -0,0 +1,131 @@
+<template>
+  <div class="dept-selector">
+    <el-tree-select
+      v-if="useTree"
+      :model-value="modelValue"
+      @update:model-value="handleChange"
+      :data="treeData"
+      :props="treeProps"
+      value-key="id"
+      :placeholder="placeholder"
+      :clearable="clearable"
+      :multiple="multiple"
+      :disabled="disabled"
+      check-strictly
+      :style="{ width: width }"
+    />
+    <el-select
+      v-else
+      :model-value="modelValue"
+      @update:model-value="handleChange"
+      :placeholder="placeholder"
+      :clearable="clearable"
+      :multiple="multiple"
+      :disabled="disabled"
+      :style="{ width: width }"
+    >
+      <el-option
+        v-for="dept in flatOptions"
+        :key="dept.id"
+        :label="dept.label"
+        :value="dept.id"
+      >
+        <span class="dept-selector__option">
+          <span class="dept-selector__icon">{{ dept.icon }}</span>
+          <span>{{ dept.label }}</span>
+        </span>
+      </el-option>
+    </el-select>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  modelValue: {
+    type: [String, Number, Array],
+    default: undefined
+  },
+  useTree: {
+    type: Boolean,
+    default: false
+  },
+  treeData: {
+    type: Array,
+    default: () => []
+  },
+  multiple: {
+    type: Boolean,
+    default: false
+  },
+  placeholder: {
+    type: String,
+    default: '请选择部门'
+  },
+  clearable: {
+    type: Boolean,
+    default: true
+  },
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  width: {
+    type: String,
+    default: '100%'
+  }
+})
+
+const emit = defineEmits(['update:modelValue', 'change'])
+
+const defaultDeptList = [
+  { id: 1, label: '数智房地事业部', icon: '🏢' },
+  { id: 2, label: '数字孪生事业部', icon: '🔮' },
+  { id: 3, label: '策划研发运维部', icon: '📋' },
+  { id: 4, label: '综合战略事业部', icon: '🎯' },
+  { id: 5, label: '商务拓展运营部', icon: '💼' },
+  { id: 6, label: '国土空间数据部', icon: '🌏' }
+]
+
+const treeProps = {
+  value: 'id',
+  label: 'label',
+  children: 'children'
+}
+
+const flatOptions = computed(() => {
+  if (props.treeData && props.treeData.length > 0) {
+    // Flatten tree data for select mode
+    function flatten(nodes) {
+      let result = []
+      for (const node of nodes) {
+        result.push({ id: node.id, label: node.label })
+        if (node.children) {
+          result = result.concat(flatten(node.children))
+        }
+      }
+      return result
+    }
+    return flatten(props.treeData)
+  }
+  return defaultDeptList
+})
+
+function handleChange(val) {
+  emit('update:modelValue', val)
+  emit('change', val)
+}
+</script>
+
+<style lang="scss" scoped>
+.dept-selector {
+  &__option {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+  }
+
+  &__icon {
+    font-size: 14px;
+  }
+}
+</style>

+ 173 - 0
src/components/performance/OutputForm.vue

@@ -0,0 +1,173 @@
+<template>
+  <el-form :model="formData" :rules="rules" ref="formRef" label-width="140px" class="output-form">
+    <el-form-item label="项目" prop="projectId">
+      <el-select v-model="formData.projectId" placeholder="请选择项目" :disabled="disabled" filterable style="width: 100%">
+        <el-option v-for="item in projectOptions" :key="item.projectId" :label="item.projectName" :value="item.projectId">
+          <span>{{ item.projectName }}</span>
+          <span style="float: right; color: #909399; font-size: 12px">{{ item.deptName }}</span>
+        </el-option>
+      </el-select>
+    </el-form-item>
+
+    <el-form-item label="填报月份" prop="month">
+      <el-date-picker v-model="formData.month" type="month" value-format="YYYY-MM" placeholder="请选择填报月份" :disabled="disabled" style="width: 100%" />
+    </el-form-item>
+
+    <el-form-item label="当月实际进度" prop="currentProgress">
+      <div class="output-form__progress">
+        <el-slider v-model="formData.currentProgress" :min="0" :max="100" :step="0.5" :marks="progressMarks" show-input :format-tooltip="formatProgress" />
+        <span class="output-form__progress-unit">%</span>
+      </div>
+    </el-form-item>
+
+    <el-form-item label="当月实际产值" prop="currentOutput">
+      <el-input-number v-model="formData.currentOutput" :precision="4" :min="0" :max="maxOutput" placeholder="请输入当月实际产值" style="width: 100%">
+        <template #suffix>万元</template>
+      </el-input-number>
+      <div v-if="maxOutput" class="output-form__hint">该项目总产值上限 {{ maxOutput }} 万元</div>
+    </el-form-item>
+
+    <el-form-item label="协作产值" prop="coopOutput" v-if="showCooperation">
+      <el-input-number v-model="formData.coopOutput" :precision="4" :min="0" placeholder="请输入协作产值(如有)" style="width: 100%">
+        <template #suffix>万元</template>
+      </el-input-number>
+    </el-form-item>
+
+    <el-form-item label="进度说明" prop="description">
+      <el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入进度说明(可选)" maxlength="500" show-word-limit />
+    </el-form-item>
+
+    <el-alert
+      v-if="validationMessage"
+      :title="validationMessage"
+      :type="validationType"
+      :closable="false"
+      show-icon
+      style="margin-bottom: 18px"
+    />
+  </el-form>
+</template>
+
+<script setup>
+const props = defineProps({
+  modelValue: {
+    type: Object,
+    default: () => ({})
+  },
+  projectOptions: {
+    type: Array,
+    default: () => []
+  },
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  showCooperation: {
+    type: Boolean,
+    default: false
+  },
+  projectTotalOutput: {
+    type: Number,
+    default: undefined
+  }
+})
+
+const emit = defineEmits(['update:modelValue', 'validate'])
+
+const formRef = ref(null)
+
+const progressMarks = {
+  0: '0%',
+  25: '25%',
+  50: '50%',
+  75: '75%',
+  100: '100%'
+}
+
+const maxOutput = computed(() => props.projectTotalOutput)
+
+const formData = computed({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const rules = {
+  projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
+  month: [{ required: true, message: '请选择填报月份', trigger: 'change' }],
+  currentProgress: [
+    { required: true, message: '请输入当月实际进度', trigger: 'blur' },
+    { type: 'number', min: 0, max: 100, message: '进度范围 0-100%', trigger: 'blur' }
+  ],
+  currentOutput: [
+    { required: true, message: '请输入当月实际产值', trigger: 'blur' },
+    { type: 'number', min: 0, message: '产值不能为负数', trigger: 'blur' }
+  ]
+}
+
+const validationMessage = computed(() => {
+  const output = formData.value.currentOutput
+  const progress = formData.value.currentProgress
+  const max = maxOutput.value
+  if (output != null && output < 0) {
+    return '当月产值不能为负数'
+  }
+  if (max != null && output > max) {
+    return `当月产值 ${output} 超过项目总产值的 ${(output - max).toFixed(4)} 万元`
+  }
+  if (progress != null && (progress < 0 || progress > 100)) {
+    return '进度百分比必须在 0-100% 之间'
+  }
+  return ''
+})
+
+const validationType = computed(() => {
+  const msg = validationMessage.value
+  if (!msg) return 'success'
+  return 'error'
+})
+
+function formatProgress(val) {
+  return val + '%'
+}
+
+function validate() {
+  return new Promise((resolve) => {
+    formRef.value?.validate((valid) => {
+      resolve(valid)
+    })
+  })
+}
+
+function resetFields() {
+  formRef.value?.resetFields()
+}
+
+defineExpose({ validate, resetFields })
+</script>
+
+<style lang="scss" scoped>
+.output-form {
+  &__progress {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    width: 100%;
+
+    .el-slider {
+      flex: 1;
+    }
+  }
+
+  &__progress-unit {
+    font-size: 14px;
+    color: #606266;
+    flex-shrink: 0;
+  }
+
+  &__hint {
+    font-size: 12px;
+    color: #909399;
+    margin-top: 4px;
+  }
+}
+</style>

+ 148 - 0
src/components/performance/PerformanceChart.vue

@@ -0,0 +1,148 @@
+<template>
+  <div class="performance-chart" ref="chartRef" :style="{ height: height + 'px' }" />
+</template>
+
+<script setup>
+import * as echarts from 'echarts'
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'bar',
+    validator: (v) => ['bar', 'line', 'pie'].includes(v)
+  },
+  height: {
+    type: Number,
+    default: 350
+  },
+  xData: {
+    type: Array,
+    default: () => []
+  },
+  seriesData: {
+    type: Array,
+    default: () => []
+  },
+  title: {
+    type: String,
+    default: ''
+  },
+  yName: {
+    type: String,
+    default: '万元'
+  },
+  color: {
+    type: String,
+    default: '#409EFF'
+  },
+  smooth: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const chartRef = ref(null)
+let chartInstance = null
+
+function getBaseOption() {
+  const base = {
+    title: props.title ? {
+      text: props.title,
+      left: 'center',
+      textStyle: { fontSize: 14, fontWeight: 'normal' }
+    } : undefined,
+    tooltip: { trigger: 'axis' },
+    grid: { left: '3%', right: '4%', bottom: '3%', top: props.title ? '45px' : '10px', containLabel: true },
+    xAxis: {
+      type: 'category',
+      data: props.xData,
+      axisLabel: { rotate: props.xData.length > 6 ? 30 : 0 }
+    },
+    yAxis: {
+      type: 'value',
+      name: props.yName
+    }
+  }
+
+  if (props.type === 'pie') {
+    return {
+      title: props.title ? {
+        text: props.title,
+        left: 'center',
+        textStyle: { fontSize: 14, fontWeight: 'normal' }
+      } : undefined,
+      tooltip: { trigger: 'item', formatter: '{b}: {c} 万元 ({d}%)' },
+      series: [{
+        type: 'pie',
+        radius: ['40%', '70%'],
+        center: ['50%', '55%'],
+        data: props.xData.map((name, i) => ({
+          name,
+          value: props.seriesData[0]?.data?.[i] || 0
+        })),
+        emphasis: {
+          itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
+        }
+      }]
+    }
+  }
+
+  return base
+}
+
+function renderChart() {
+  if (!chartRef.value) return
+
+  chartInstance = echarts.init(chartRef.value)
+
+  const option = getBaseOption()
+
+  if (props.type !== 'pie') {
+    option.series = props.seriesData.map(series => ({
+      type: props.type,
+      data: series.data || [],
+      name: series.name || '',
+      smooth: props.type === 'line' ? props.smooth : undefined,
+      itemStyle: { color: series.color || props.color }
+    }))
+  }
+
+  chartInstance.setOption(option)
+}
+
+function updateChart() {
+  if (!chartInstance) {
+    renderChart()
+    return
+  }
+  const option = getBaseOption()
+  if (props.type !== 'pie') {
+    option.series = props.seriesData.map(series => ({
+      type: props.type,
+      data: series.data || [],
+      name: series.name || '',
+      smooth: props.type === 'line' ? props.smooth : undefined,
+      itemStyle: { color: series.color || props.color }
+    }))
+  }
+  chartInstance.setOption(option, true)
+}
+
+watch(() => [props.xData, props.seriesData, props.type], () => {
+  nextTick(updateChart)
+}, { deep: true })
+
+onMounted(() => {
+  nextTick(renderChart)
+})
+
+onUnmounted(() => {
+  chartInstance?.dispose()
+})
+</script>
+
+<style lang="scss" scoped>
+.performance-chart {
+  width: 100%;
+}
+</style>

+ 185 - 0
src/components/performance/ProjectCard.vue

@@ -0,0 +1,185 @@
+<template>
+  <el-card shadow="hover" class="project-card" :class="{ 'project-card--clickable': clickable }" @click="handleClick">
+    <div class="project-card__header">
+      <h4 class="project-card__title">{{ project.projectName }}</h4>
+      <ReviewStatusTag :status="project.status" :options="statusOptions" />
+    </div>
+    <el-divider />
+    <div class="project-card__body">
+      <div class="project-card__info">
+        <span class="project-card__label">项目类型</span>
+        <span class="project-card__value">{{ typeLabel }}</span>
+      </div>
+      <div class="project-card__info">
+        <span class="project-card__label">牵头部门</span>
+        <span class="project-card__value">{{ project.deptName }}</span>
+      </div>
+      <div class="project-card__info" v-if="project.coopDeptName">
+        <span class="project-card__label">协作部门</span>
+        <span class="project-card__value">{{ project.coopDeptName }}</span>
+      </div>
+      <div class="project-card__info" v-if="project.contractAmount">
+        <span class="project-card__label">合同额</span>
+        <span class="project-card__value">{{ project.contractAmount }} 万元</span>
+      </div>
+    </div>
+    <el-divider />
+    <div class="project-card__footer">
+      <div class="project-card__output">
+        <span class="project-card__output-label">预估产值</span>
+        <span class="project-card__output-value project-card__output-value--estimated">
+          {{ project.estimatedOutput || '--' }} <small>万元</small>
+        </span>
+      </div>
+      <div class="project-card__output">
+        <span class="project-card__output-label">实际产值</span>
+        <span class="project-card__output-value project-card__output-value--actual">
+          {{ project.actualOutput || '--' }} <small>万元</small>
+        </span>
+      </div>
+    </div>
+    <div class="project-card__actions" v-if="$slots.actions">
+      <slot name="actions" />
+    </div>
+  </el-card>
+</template>
+
+<script setup>
+import ReviewStatusTag from './ReviewStatusTag.vue'
+
+const props = defineProps({
+  project: {
+    type: Object,
+    required: true
+  },
+  clickable: {
+    type: Boolean,
+    default: false
+  },
+  statusOptions: {
+    type: Array,
+    default: () => [
+      { label: '已完成招投标在建', value: 'BIDDING_IN_PROGRESS', tagType: 'warning' },
+      { label: '前期策划但未开工', value: 'PLANNING_NOT_STARTED', tagType: 'info' },
+      { label: '前期策划并且同步开工', value: 'PLANNING_IN_PROGRESS', tagType: '' },
+      { label: '完工', value: 'COMPLETED', tagType: 'success' },
+      { label: '作废', value: 'CANCELLED', tagType: 'danger' }
+    ]
+  },
+  typeMap: {
+    type: Object,
+    default: () => ({
+      'ENGINEERING': '工程项目',
+      'TECHNICAL_SERVICE': '技术服务',
+      'CONSULTING': '咨询服务',
+      'OTHER': '其他'
+    })
+  }
+})
+
+const emit = defineEmits(['click'])
+
+const typeLabel = computed(() => props.typeMap[props.project.projectType] || props.project.projectType)
+
+function handleClick() {
+  if (props.clickable) {
+    emit('click', props.project)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.project-card {
+  border-radius: 6px;
+  transition: all 0.3s;
+
+  &--clickable {
+    cursor: pointer;
+    &:hover {
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+    }
+  }
+
+  &__header {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: 8px;
+  }
+
+  &__title {
+    margin: 0;
+    font-size: 15px;
+    font-weight: 600;
+    color: #303133;
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__body {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+  }
+
+  &__info {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+
+  &__label {
+    font-size: 13px;
+    color: #909399;
+  }
+
+  &__value {
+    font-size: 13px;
+    color: #606266;
+  }
+
+  &__footer {
+    display: flex;
+    justify-content: space-around;
+  }
+
+  &__output {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 4px;
+  }
+
+  &__output-label {
+    font-size: 12px;
+    color: #909399;
+  }
+
+  &__output-value {
+    font-size: 18px;
+    font-weight: bold;
+
+    small {
+      font-size: 12px;
+      font-weight: normal;
+    }
+
+    &--estimated {
+      color: #E6A23C;
+    }
+
+    &--actual {
+      color: #409EFF;
+    }
+  }
+
+  &__actions {
+    margin-top: 12px;
+    display: flex;
+    justify-content: center;
+    gap: 8px;
+  }
+}
+</style>

+ 56 - 0
src/components/performance/ReviewStatusTag.vue

@@ -0,0 +1,56 @@
+<template>
+  <el-tag :type="tagType" :size="size" :effect="effect" class="review-status-tag">
+    <svg-icon v-if="showIcon && icon" :icon-class="icon" class="review-status-tag__icon" />
+    {{ label }}
+  </el-tag>
+</template>
+
+<script setup>
+const props = defineProps({
+  status: {
+    type: String,
+    default: ''
+  },
+  options: {
+    type: Array,
+    default: () => [
+      { label: '待审核', value: 'PENDING', tagType: 'warning', icon: 'time' },
+      { label: '审核通过', value: 'APPROVED', tagType: 'success', icon: 'circle-check' },
+      { label: '审核不通过', value: 'REJECTED', tagType: 'danger', icon: 'circle-close' },
+      { label: '待提交', value: 'DRAFT', tagType: 'info', icon: 'edit' },
+      { label: '已提交待审核', value: 'SUBMITTED', tagType: 'warning', icon: 'clock' }
+    ]
+  },
+  size: {
+    type: String,
+    default: 'default',
+    validator: (v) => ['large', 'default', 'small'].includes(v)
+  },
+  effect: {
+    type: String,
+    default: 'light',
+    validator: (v) => ['dark', 'light', 'plain'].includes(v)
+  },
+  showIcon: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const currentOption = computed(() => {
+  return props.options.find(o => o.value === props.status) || {}
+})
+
+const label = computed(() => currentOption.value.label || props.status || '未知')
+const tagType = computed(() => currentOption.value.tagType || 'info')
+const icon = computed(() => currentOption.value.icon || '')
+</script>
+
+<style lang="scss" scoped>
+.review-status-tag {
+  &__icon {
+    margin-right: 4px;
+    vertical-align: middle;
+  }
+}
+</style>

+ 6 - 0
src/components/performance/index.js

@@ -0,0 +1,6 @@
+export { default as ProjectCard } from './ProjectCard.vue'
+export { default as OutputForm } from './OutputForm.vue'
+export { default as ReviewStatusTag } from './ReviewStatusTag.vue'
+export { default as CooperationTable } from './CooperationTable.vue'
+export { default as PerformanceChart } from './PerformanceChart.vue'
+export { default as DeptSelector } from './DeptSelector.vue'