5 小时以前 07cfe16049ada779c8c820b809c6531e69e84bc1
feat(home): 重构首页工作台界面并新增全息牙齿组件

- 使用新组件化架构重构首页,提升用户体验和代码可维护性
- 新增 HolographicTooth 全息牙齿3D可视化组件,基于Three.js实现
- 集成多种数据展示图表组件,包括工序统计、合同分析、财务状况等
- 优化页面布局结构,采用响应式设计提升移动端适配效果
- 移除旧版样式定义,引入设计系统tokens统一视觉规范
- 优化资源管理,改进组件生命周期和内存释放机制
已添加15个文件
已修改7个文件
3533 ■■■■■ 文件已修改
docs/img.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/logo/JCCKLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
multiple/assets/screen/login-background.png 补丁 | 查看 | 原始文档 | blame | 历史
package.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/login/JCCKLogo.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/login/login-background.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/styles/index.scss 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/HolographicTooth.vue 352 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/HomePanel.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/HomeQuickEntry.vue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/HomeStats.vue 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/HomeTodo.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/HomeWelcome.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/charts/HomeContractChart.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/charts/HomeFinanceChart.vue 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/charts/HomeInvoiceChart.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/charts/HomeProcessChart.vue 289 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/home/components/charts/HomeQualityChart.vue 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/index.vue 1314 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/login.vue 326 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/login/components/LoginBrand.vue 230 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/img.png
multiple/assets/logo/JCCKLogo.png

multiple/assets/screen/login-background.png

package.json
@@ -38,6 +38,7 @@
    "qrcode": "^1.5.4",
    "sortablejs": "^1.15.6",
    "splitpanes": "3.1.5",
    "three": "^0.186.0",
    "vue": "3.4.31",
    "vue-cropper": "1.1.1",
    "vue-easy-lightbox": "^1.19.0",
src/assets/login/JCCKLogo.png
src/assets/login/login-background.png
src/assets/styles/index.scss
@@ -1,3 +1,4 @@
@import './tokens.scss';
@import './variables.module.scss';
@import './mixin.scss';
@import './transition.scss';
@@ -95,29 +96,6 @@
    content: " ";
    clear: both;
    height: 0;
  }
}
aside {
  background: #eef1f6;
  padding: 8px 24px;
  margin-bottom: 20px;
  border-radius: 2px;
  display: block;
  line-height: 32px;
  font-size: 16px;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
  color: #2c3e50;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  a {
    color: #337ab7;
    cursor: pointer;
    &:hover {
      color: rgb(32, 160, 255);
    }
  }
}
src/components/HolographicTooth.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,352 @@
<template>
  <div ref="host" class="holo-tooth" aria-label="全息牙齿">
    <div class="holo-grid"></div>
    <div class="holo-label">DIGITAL DENTAL Â· 3D ANALYSIS</div>
  </div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
const host = ref(null)
let renderer, scene, camera, controls, animationId
let tooth, toothPoints, scanRing, scanLine, particleField
let resizeObserver
// ç”Ÿæˆä¸€ä¸ªæ— éœ€ä¸‹è½½æ¨¡åž‹çš„“牙齿”参数曲面。
// åŽç»­å¦‚果有真实 tooth.glb,可直接替换 buildProceduralTooth()。
function buildProceduralTooth() {
  const positions = []
  const normals = []
  const uvs = []
  const indices = []
  // v: 0 ç‰™æ ¹åº• -> 1 ç‰™å† é¡¶ï¼›a=0 ä¸ºå·¦å³å¤–侧(牙尖方向),a=±π/2 ä¸ºæ­£ä¸­ï¼ˆå‡¹é™·æ–¹å‘)
  const rows = 60
  const cols = 84
  const FLAT = 0.62 // å‰åŽåŽ‹æ‰ï¼Œå‘ˆçŽ°å¡é€šç‰™æ¯”ä¾‹
  for (let iy = 0; iy <= rows; iy++) {
    const v = iy / rows
    let radius
    let y
    let dip = 0
    let notch = 0
    let rootTaper = 0
    let tipDrop = 0
    let tipPinch = 1
    if (v < 0.42) {
      // ç‰™æ ¹ï¼šç²—短双根,根尖收细圆钝,中央深 U å½¢åˆ†å‰
      const t = v / 0.42
      if (t < 0.45) tipPinch = 0.78 + 0.22 * Math.pow(t / 0.45, 1.3)
      radius = 0.42 + 0.16 * Math.pow(t, 0.75)
      y = -1.28 + t * 1.18
      notch = 0.66 * Math.pow(1 - t, 1.4)
      rootTaper = 1 - t
      tipDrop = 0.12 * rootTaper
    } else {
      // ç‰™å† ï¼šåœ†é¡¶é¼“冠、咬合面平缓中央沟
      const t = (v - 0.42) / 0.58
      radius = 0.58 + 0.4 * Math.pow(Math.sin(t * Math.PI * 0.72), 1.25)
      y = -0.1 + t * 1.48
      if (t > 0.55) dip = 0.26 * Math.pow((t - 0.55) / 0.45, 1.15)
      if (t > 0.78) radius *= Math.sqrt(Math.max(0, 1 - Math.pow(Math.min(1, (t - 0.78) / 0.22), 2)))
    }
    for (let ix = 0; ix <= cols; ix++) {
      const u = ix / cols
      const a = u * Math.PI * 2
      const cosA = Math.cos(a)
      const absC = Math.abs(cosA)
      const center = Math.pow(1 - absC, 0.9) // æ­£ä¸­=1,两侧牙尖=0,缓指数让分叉更宽
      // æ ¹å°–轻度外撇并收细,形成两个圆钝独立根
      const rr = radius * tipPinch * (1 + 0.26 * rootTaper * rootTaper * absC)
      // å’¬åˆé¢ï¼šå·¦å³åŒç‰™å°–、中央下凹;牙根:中央上抬形成分叉、根尖下探但指数<1 ä½¿è½®å»“圆钝
      const yy = y - dip * center + notch * center - tipDrop * Math.pow(absC, 0.55)
      // æ ¹éƒ¨æ•´ä½“向左右外弯,呈双根外撇的弧线
      const x = cosA * rr + 0.14 * rootTaper * rootTaper * cosA
      const z = Math.sin(a) * rr * FLAT
      positions.push(x, yy, z)
      normals.push(x, 0, z)
      uvs.push(u, v)
    }
  }
  for (let iy = 0; iy < rows; iy++) {
    for (let ix = 0; ix < cols; ix++) {
      const a = iy * (cols + 1) + ix
      const b = a + 1
      const c = a + cols + 1
      const d = c + 1
      indices.push(a, c, b, b, c, d)
    }
  }
  const geometry = new THREE.BufferGeometry()
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
  geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
  geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
  geometry.setIndex(indices)
  geometry.computeVertexNormals()
  // ä¸»ä½“:半透明青白色全息材质
  const material = new THREE.MeshBasicMaterial({
    color: 0x6fd4ff,
    transparent: true,
    opacity: 0.08,
    side: THREE.DoubleSide,
    depthWrite: false,
    blending: THREE.AdditiveBlending
  })
  tooth = new THREE.Mesh(geometry, material)
  // ç½‘格线
  const wire = new THREE.LineSegments(
    new THREE.WireframeGeometry(geometry),
    new THREE.LineBasicMaterial({
      color: 0x4fdcff,
      transparent: true,
      opacity: 0.72,
      blending: THREE.AdditiveBlending
    })
  )
  tooth.add(wire)
  // ç‚¹äº‘
  const pointGeo = geometry.clone()
  const pointMat = new THREE.PointsMaterial({
    color: 0x7fd8ff,
    size: 0.02,
    transparent: true,
    opacity: 0.5,
    blending: THREE.AdditiveBlending,
    depthWrite: false
  })
  toothPoints = new THREE.Points(pointGeo, pointMat)
  tooth.add(toothPoints)
  return tooth
}
function buildParticles() {
  const count = 1500
  const arr = new Float32Array(count * 3)
  for (let i = 0; i < count; i++) {
    const r = 1.7 + Math.random() * 2.8
    const a = Math.random() * Math.PI * 2
    const y = (Math.random() - 0.5) * 4.2
    arr[i * 3] = Math.cos(a) * r * (0.7 + Math.random() * 0.5)
    arr[i * 3 + 1] = y
    arr[i * 3 + 2] = Math.sin(a) * r * (0.45 + Math.random() * 0.5)
  }
  const geo = new THREE.BufferGeometry()
  geo.setAttribute('position', new THREE.BufferAttribute(arr, 3))
  const mat = new THREE.PointsMaterial({
    color: 0x62ddff,
    size: 0.018,
    transparent: true,
    opacity: 0.55,
    blending: THREE.AdditiveBlending,
    depthWrite: false
  })
  particleField = new THREE.Points(geo, mat)
  scene.add(particleField)
}
function buildScanSystem() {
  scanRing = new THREE.Mesh(
    new THREE.TorusGeometry(1.0, 0.012, 8, 96),
    new THREE.MeshBasicMaterial({
      color: 0x55e7ff,
      transparent: true,
      opacity: 0.9,
      blending: THREE.AdditiveBlending
    })
  )
  scanRing.rotation.x = Math.PI / 2
  scanRing.position.y = -1.52
  scene.add(scanRing)
  const lineGeo = new THREE.PlaneGeometry(2.2, 0.018)
  const lineMat = new THREE.MeshBasicMaterial({
    color: 0x7feeff,
    transparent: true,
    opacity: 0.8,
    blending: THREE.AdditiveBlending,
    side: THREE.DoubleSide
  })
  scanLine = new THREE.Mesh(lineGeo, lineMat)
  scanLine.position.y = -1.55
  scene.add(scanLine)
}
function init() {
  scene = new THREE.Scene()
  camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
  camera.position.set(0, 0.1, 6.6)
  renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    powerPreference: 'high-performance'
  })
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  renderer.setClearColor(0x000000, 0)
  renderer.outputColorSpace = THREE.SRGBColorSpace
  renderer.toneMapping = THREE.ACESFilmicToneMapping
  renderer.toneMappingExposure = 1.15
  host.value.appendChild(renderer.domElement)
  // æŸ”和的立体光
  scene.add(new THREE.AmbientLight(0x7bdfff, 2.0))
  const key = new THREE.PointLight(0x55dfff, 7, 10)
  key.position.set(2.2, 2.8, 3.8)
  scene.add(key)
  const rim = new THREE.PointLight(0x197cff, 5, 8)
  rim.position.set(-3, 0, -2)
  scene.add(rim)
  tooth = buildProceduralTooth()
  scene.add(tooth)
  buildParticles()
  buildScanSystem()
  controls = new OrbitControls(camera, renderer.domElement)
  controls.enableDamping = true
  controls.enablePan = false
  controls.enableZoom = false
  controls.autoRotate = true
  controls.autoRotateSpeed = 0.65
  // ä¸ä½¿ç”¨ EffectComposer:bloom åŽæœŸé€šé“会丢失画布 alpha,导致透明背景变灰白
  resizeObserver = new ResizeObserver(resize)
  resizeObserver.observe(host.value)
  resize()
  animate()
}
function resize() {
  if (!host.value || !renderer) return
  const { width, height } = host.value.getBoundingClientRect()
  const w = Math.max(1, width)
  const h = Math.max(1, height)
  renderer.setSize(w, h, false)
  camera.aspect = w / h
  camera.updateProjectionMatrix()
}
function animate(time = 0) {
  animationId = requestAnimationFrame(animate)
  const t = time * 0.001
  if (tooth) {
    tooth.rotation.y = Math.sin(t * 0.35) * 0.22
    tooth.position.y = Math.sin(t * 1.15) * 0.045
  }
  if (toothPoints) {
    toothPoints.rotation.y = -t * 0.08
    toothPoints.material.opacity = 0.42 + Math.sin(t * 2.8) * 0.16
  }
  if (particleField) {
    particleField.rotation.y = t * 0.045
    particleField.rotation.x = Math.sin(t * 0.2) * 0.08
  }
  if (scanRing) {
    scanRing.position.y = -1.46 + ((t * 0.7) % 2.86)
    scanRing.scale.setScalar(0.7 + Math.sin(t * 3) * 0.08)
  }
  if (scanLine) {
    scanLine.position.y = -1.62 + ((t * 0.72) % 3.1)
    scanLine.material.opacity = 0.2 + Math.abs(Math.sin(t * 5)) * 0.4
  }
  controls?.update()
  renderer.render(scene, camera)
}
onMounted(init)
onBeforeUnmount(() => {
  cancelAnimationFrame(animationId)
  resizeObserver?.disconnect()
  controls?.dispose()
  renderer?.dispose()
  if (host.value && renderer?.domElement?.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement)
  }
})
</script>
<style scoped>
.holo-tooth {
  position: relative;
  width: 100%;
  height: 100%;
  min-height: 260px;
  overflow: hidden;
  pointer-events: auto;
}
.holo-tooth canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  filter: drop-shadow(0 0 6px rgba(85, 231, 255, 0.55));
}
.holo-grid {
  position: absolute;
  inset: 8% 10%;
  border: 1px solid rgba(82, 211, 255, 0.16);
  border-radius: 50%;
  background:
    linear-gradient(rgba(72, 214, 255, 0.07) 1px, transparent 1px),
    linear-gradient(90deg, rgba(72, 214, 255, 0.07) 1px, transparent 1px);
  background-size: 28px 28px;
  mask-image: radial-gradient(ellipse, black 30%, transparent 72%);
  pointer-events: none;
}
.holo-label {
  position: absolute;
  left: 50%;
  bottom: 8%;
  transform: translateX(-50%);
  font: 500 9px/1.2 Inter, "Segoe UI", sans-serif;
  letter-spacing: 0.24em;
  color: rgba(57, 185, 235, 0.62);
  white-space: nowrap;
  pointer-events: none;
}
</style>
src/views/home/components/HomePanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
<template>
  <section class="app-panel home-panel">
    <header v-if="title || $slots.extra" class="home-panel__head">
      <div class="home-panel__title-wrap">
        <h3 class="home-panel__title">{{ title }}</h3>
        <span v-if="subtitle" class="home-panel__subtitle">{{ subtitle }}</span>
      </div>
      <div class="home-panel__extra">
        <slot name="extra"></slot>
      </div>
    </header>
    <div v-loading="loading" class="home-panel__body" :style="bodyStyle">
      <slot></slot>
    </div>
  </section>
</template>
<script setup>
defineProps({
  title: { type: String, default: '' },
  subtitle: { type: String, default: '' },
  loading: { type: Boolean, default: false },
  bodyStyle: { type: [String, Object], default: '' }
})
</script>
<style scoped>
.home-panel {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.home-panel__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 10px;
  padding: 14px 16px 0;
}
.home-panel__title-wrap {
  display: flex;
  align-items: baseline;
  gap: 8px;
  min-width: 0;
}
.home-panel__title {
  position: relative;
  margin: 0;
  padding-left: 10px;
  font-size: 15px;
  font-weight: 600;
  color: var(--app-text);
}
.home-panel__title::before {
  content: '';
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 3px;
  height: 14px;
  border-radius: 2px;
  background: var(--app-primary);
}
.home-panel__subtitle {
  font-size: 12px;
  color: var(--app-text-tertiary);
}
.home-panel__extra {
  display: flex;
  align-items: center;
  gap: 8px;
}
.home-panel__body {
  flex: 1;
  min-height: 0;
  padding: 14px 16px 16px;
}
</style>
src/views/home/components/HomeQuickEntry.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,120 @@
<template>
  <HomePanel title="快捷入口" subtitle="按权限自动展示常用功能">
    <div class="entry">
      <div
        v-for="item in entries"
        :key="item.title"
        class="entry__item"
        role="link"
        tabindex="0"
        @click="go(item.path)"
        @keyup.enter="go(item.path)"
      >
        <div class="entry__icon">
          <el-icon :size="18"><component :is="item.icon" /></el-icon>
        </div>
        <span class="entry__label">{{ item.title }}</span>
      </div>
      <el-empty v-if="!entries.length" description="暂无可用入口" :image-size="48" />
    </div>
  </HomePanel>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import HomePanel from './HomePanel.vue'
const router = useRouter()
// æœŸæœ›å±•示的常用入口(按业务顺序);用户无对应菜单权限时自动跳过
const WANTED = [
  { title: '生产订单', icon: 'Tickets' },
  { title: '生产工单', icon: 'List' },
  { title: '生产报工', icon: 'DataLine' },
  { title: '入库管理', icon: 'Download' },
  { title: '出库台账', icon: 'Upload' },
  { title: '库存管理', icon: 'Box' },
  { title: '原材料检验', icon: 'Aim' },
  { title: '出厂检验', icon: 'CircleCheck' },
  { title: '客户档案', icon: 'User' },
  { title: '销售台账', icon: 'Money' },
  { title: '采购台账', icon: 'ShoppingCart' },
  { title: '设备台账', icon: 'Cpu' }
]
// ä»Žå·²æ³¨å†Œè·¯ç”±ï¼ˆåŽç«¯èœå•按权限生成)中建立 æ ‡é¢˜ -> å®Œæ•´è·¯å¾„ æ˜ å°„
const entries = computed(() => {
  const titlePath = new Map()
  router.getRoutes().forEach((r) => {
    if (r.meta && r.meta.title && !titlePath.has(r.meta.title)) {
      titlePath.set(r.meta.title, r.path)
    }
  })
  return WANTED
    .filter((w) => titlePath.has(w.title))
    .map((w) => ({ ...w, path: titlePath.get(w.title) }))
})
const go = (path) => router.push(path)
</script>
<style scoped>
.entry {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px;
}
.entry__item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 14px 6px;
  border-radius: var(--app-radius-sm);
  border: 1px solid transparent;
  cursor: pointer;
  transition: background var(--app-transition), border-color var(--app-transition);
  outline: none;
}
.entry__item:hover,
.entry__item:focus-visible {
  background: var(--app-primary-soft);
  border-color: var(--app-border);
}
.entry__icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--app-surface-hover);
  color: var(--app-primary);
  transition: background var(--app-transition);
}
.entry__item:hover .entry__icon {
  background: var(--app-surface);
}
.entry__label {
  font-size: 12px;
  color: var(--app-text-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
@media (max-width: 1440px) {
  .entry { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 768px) {
  .entry { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
src/views/home/components/HomeStats.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
<template>
  <div class="stats">
    <div v-for="(item, i) in cards" :key="item.key" class="app-panel stat-card">
      <div class="stat-card__icon" :class="`is-${item.tone}`">
        <el-icon :size="18"><component :is="item.icon" /></el-icon>
      </div>
      <div class="stat-card__main">
        <div class="stat-card__label">{{ item.label }}</div>
        <div class="stat-card__value app-num">{{ formatted[i] }}<span class="stat-card__unit">{{ item.unit }}</span></div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { computed } from 'vue'
import {
  Money, DocumentCopy, ShoppingCart, CreditCard, Box, Van
} from '@element-plus/icons-vue'
import { useCountUp, formatNum } from '../useCountUp'
const props = defineProps({
  business: { type: Object, default: () => ({}) }
})
const cards = [
  { key: 'monthSaleMoney', label: '本月销售额', unit: '元', icon: Money, tone: 'primary' },
  { key: 'monthSaleHaveMoney', label: '未开票金额', unit: '元', icon: DocumentCopy, tone: 'warning' },
  { key: 'monthPurchaseMoney', label: '本月采购额', unit: '元', icon: ShoppingCart, tone: 'info' },
  { key: 'monthPurchaseHaveMoney', label: '待付款金额', unit: '元', icon: CreditCard, tone: 'danger' },
  { key: 'inventoryNum', label: '当前库存总量', unit: 'ä»¶', icon: Box, tone: 'success' },
  { key: 'todayInventoryNum', label: '今日入库', unit: 'ä»¶', icon: Van, tone: 'primary' }
]
const sources = cards.map((c) => computed(() => Number(props.business[c.key]) || 0))
const values = sources.map((s) => useCountUp(s))
const formatted = computed(() => values.map((v) => formatNum(v.value)))
</script>
<style scoped>
.stats {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: var(--app-gap);
}
.stat-card {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px;
  min-width: 0;
  transition: box-shadow var(--app-transition), transform var(--app-transition);
}
.stat-card:hover {
  box-shadow: var(--app-shadow-md);
  transform: translateY(-1px);
}
.stat-card__icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  border-radius: var(--app-radius-sm);
}
.stat-card__icon.is-primary { background: var(--app-primary-soft); color: var(--app-primary); }
.stat-card__icon.is-success { background: var(--app-success-soft); color: var(--app-success); }
.stat-card__icon.is-warning { background: var(--app-warning-soft); color: var(--app-warning); }
.stat-card__icon.is-danger { background: var(--app-danger-soft); color: var(--app-danger); }
.stat-card__icon.is-info { background: var(--app-info-soft); color: var(--app-info); }
.stat-card__main {
  min-width: 0;
}
.stat-card__label {
  font-size: 12px;
  color: var(--app-text-tertiary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.stat-card__value {
  margin-top: 4px;
  font-size: 20px;
  font-weight: 600;
  color: var(--app-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.stat-card__unit {
  margin-left: 4px;
  font-size: 12px;
  font-weight: 400;
  color: var(--app-text-tertiary);
}
@media (max-width: 1440px) {
  .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 768px) {
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
src/views/home/components/HomeTodo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,101 @@
<template>
  <HomePanel title="待办中心" :loading="loading">
    <template #extra>
      <span v-if="list.length" class="todo-badge app-num">{{ list.length }} é¡¹å¾…处理</span>
    </template>
    <ul v-if="list.length" class="todo">
      <li v-for="item in list" :key="item.id" class="todo__item">
        <div class="todo__row">
          <span class="todo__dot"></span>
          <span class="todo__reason">{{ item.approveReason || '待办事项' }}</span>
          <span class="todo__time">{{ item.approveTime }}</span>
        </div>
        <div class="todo__meta">
          <span>编号 {{ item.approveId }}</span>
          <span>部门 {{ item.approveDeptName }}</span>
        </div>
      </li>
    </ul>
    <el-empty v-else description="暂无待办事项" :image-size="56" />
  </HomePanel>
</template>
<script setup>
import HomePanel from './HomePanel.vue'
defineProps({
  list: { type: Array, default: () => [] },
  loading: { type: Boolean, default: false }
})
</script>
<style scoped>
.todo-badge {
  font-size: 12px;
  color: var(--app-warning);
  background: var(--app-warning-soft);
  border-radius: 10px;
  padding: 2px 10px;
}
.todo {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 320px;
  overflow-y: auto;
}
.todo__item {
  padding: 10px 12px;
  border-radius: var(--app-radius-sm);
  transition: background var(--app-transition);
}
.todo__item:hover {
  background: var(--app-surface-hover);
}
.todo__item + .todo__item {
  margin-top: 4px;
}
.todo__row {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.todo__dot {
  width: 6px;
  height: 6px;
  flex-shrink: 0;
  border-radius: 50%;
  background: var(--app-primary);
}
.todo__reason {
  flex: 1;
  min-width: 0;
  font-size: 13px;
  color: var(--app-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.todo__time {
  flex-shrink: 0;
  font-size: 12px;
  color: var(--app-text-tertiary);
}
.todo__meta {
  display: flex;
  gap: 16px;
  margin: 6px 0 0 14px;
  font-size: 12px;
  color: var(--app-text-tertiary);
}
</style>
src/views/home/components/HomeWelcome.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
<template>
  <section class="app-panel welcome">
    <div class="welcome__left">
      <h2 class="welcome__title">{{ greeting }},{{ userName }}</h2>
      <p class="welcome__meta">
        <span>{{ dateText }}</span>
        <span class="welcome__sep">|</span>
        <span>{{ roleName || '系统用户' }}</span>
        <template v-if="deptName">
          <span class="welcome__sep">|</span>
          <span>{{ deptName }}</span>
        </template>
      </p>
    </div>
    <div class="welcome__chips">
      <div class="welcome__chip">
        <span class="welcome__chip-label">待办任务</span>
        <span class="welcome__chip-value app-num">{{ todoCount }}</span>
      </div>
      <div class="welcome__chip">
        <span class="welcome__chip-label">待生产订单</span>
        <span class="welcome__chip-value app-num">{{ pendingOrders }}</span>
      </div>
      <div class="welcome__chip">
        <span class="welcome__chip-label">今日入库</span>
        <span class="welcome__chip-value app-num">{{ todayInbound }}</span>
      </div>
      <div class="welcome__chip">
        <span class="welcome__chip-label">今日完成检验</span>
        <span class="welcome__chip-value app-num">{{ todayChecked }}</span>
      </div>
    </div>
  </section>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs'
import { qualityInspectionCount, orderCount } from '@/api/viewIndex'
const props = defineProps({
  userName: { type: String, default: '' },
  roleName: { type: String, default: '' },
  deptName: { type: String, default: '' },
  todoCount: { type: Number, default: 0 },
  todayInbound: { type: [Number, String], default: 0 }
})
const todayChecked = ref(0)
const pendingOrders = ref(0)
const greeting = computed(() => {
  const h = dayjs().hour()
  if (h < 6) return '夜深了'
  if (h < 12) return '早上好'
  if (h < 14) return '中午好'
  if (h < 18) return '下午好'
  return '晚上好'
})
const weekMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const dateText = computed(() => {
  const d = dayjs()
  return `${d.format('YYYYå¹´MM月DD日')} ${weekMap[d.day()]}`
})
onMounted(() => {
  qualityInspectionCount().then((res) => {
    todayChecked.value = (res.data || {}).todayCompletedCount || 0
  }).catch(() => {})
  orderCount().then((res) => {
    const item = (res.data || []).find((i) => i.name === '待生产订单数')
    pendingOrders.value = item ? Number(item.value) || 0 : 0
  }).catch(() => {})
})
</script>
<style scoped>
.welcome {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 16px;
  padding: 18px 24px;
}
.welcome__title {
  margin: 0;
  font-size: 18px;
  font-weight: 600;
  color: var(--app-text);
}
.welcome__meta {
  margin: 6px 0 0;
  font-size: 13px;
  color: var(--app-text-tertiary);
}
.welcome__sep {
  margin: 0 10px;
  color: var(--app-border);
}
.welcome__chips {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.welcome__chip {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 96px;
  padding: 10px 16px;
  border: 1px solid var(--app-border);
  border-radius: var(--app-radius-sm);
  background: var(--app-surface-hover);
}
.welcome__chip-label {
  font-size: 12px;
  color: var(--app-text-tertiary);
}
.welcome__chip-value {
  font-size: 18px;
  font-weight: 600;
  color: var(--app-text);
}
</style>
src/views/home/components/charts/HomeContractChart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,186 @@
<template>
  <HomePanel title="客户合同金额分析" :loading="loading">
    <div class="contract">
      <div class="contract__head">
        <span class="contract__label">总合同金额(元)</span>
        <div class="contract__metrics">
          <span class="contract__sum app-num">{{ formatNum(sum) }}</span>
          <span class="contract__rate">周同比 <b :class="yny >= 0 ? 'is-up' : 'is-down'">{{ yny }}%</b></span>
          <span class="contract__rate">环比 <b :class="chain >= 0 ? 'is-up' : 'is-down'">{{ chain }}%</b></span>
        </div>
      </div>
      <div class="contract__body">
        <div class="contract__pie">
          <Echarts :legend="{ show: false }" :chartStyle="{ width: '100%', height: '100%' }"
            :series="pieSeries" :tooltip="pieTooltip" />
        </div>
        <ul class="contract__list">
          <li v-for="item in pieList" :key="item.name" class="contract__row">
            <span class="contract__dot" :style="{ background: item.itemStyle.color }"></span>
            <span class="contract__name" :title="item.name">{{ item.name }}</span>
            <span class="contract__rate-value app-num">{{ item.rate }}%</span>
            <span class="contract__value app-num">ï¿¥{{ formatNum(item.value) }}</span>
          </li>
          <el-empty v-if="!pieList.length" description="暂无数据" :image-size="48" />
        </ul>
      </div>
    </div>
  </HomePanel>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import HomePanel from '../HomePanel.vue'
import { analysisCustomerContractAmounts } from '@/api/viewIndex'
import { formatNum } from '../../useCountUp'
const PIE_COLORS = ['#2C51D9', '#0F9960', '#D97706', '#D54941', '#7A5AF8', '#19C6C6', '#FF8A3D', '#5B8CFF', '#B37FEB', '#1377EF']
const loading = ref(true)
const sum = ref(0)
const yny = ref(0)
const chain = ref(0)
const pieList = ref([])
const pieSeries = ref([
  {
    type: 'pie',
    radius: ['62%', '86%'],
    center: ['50%', '50%'],
    avoidLabelOverlap: false,
    itemStyle: { borderColor: '#fff', borderWidth: 2, borderRadius: 4 },
    label: { show: false },
    data: []
  }
])
const pieTooltip = {
  trigger: 'item',
  formatter: (params) => `${params.name} ${formatNum(params.value)}元 ${params.percent}%`
}
onMounted(() => {
  analysisCustomerContractAmounts().then((res) => {
    const d = res.data || {}
    sum.value = d.sum
    yny.value = d.yny
    chain.value = d.chain
    pieList.value = (d.item || []).map((item, index) => ({
      ...item,
      itemStyle: { color: PIE_COLORS[index % PIE_COLORS.length] }
    }))
    pieSeries.value[0].data = pieList.value
  }).finally(() => { loading.value = false })
})
</script>
<style scoped>
.contract {
  display: flex;
  flex-direction: column;
  gap: 12px;
  height: 100%;
}
.contract__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 8px;
  padding: 10px 14px;
  border-radius: var(--app-radius-sm);
  background: var(--app-surface-hover);
}
.contract__label {
  font-size: 13px;
  color: var(--app-text-secondary);
}
.contract__metrics {
  display: flex;
  align-items: baseline;
  gap: 16px;
}
.contract__sum {
  font-size: 20px;
  font-weight: 600;
  color: var(--app-text);
}
.contract__rate {
  font-size: 12px;
  color: var(--app-text-tertiary);
}
.contract__rate .is-up { color: var(--app-danger); }
.contract__rate .is-down { color: var(--app-success); }
.contract__body {
  display: flex;
  align-items: center;
  gap: 16px;
  flex: 1;
  min-height: 0;
}
.contract__pie {
  width: 160px;
  height: 160px;
  flex-shrink: 0;
}
.contract__list {
  flex: 1;
  min-width: 0;
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 160px;
  overflow-y: auto;
}
.contract__row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
  font-size: 13px;
  border-bottom: 1px dashed var(--app-divider);
}
.contract__row:last-child {
  border-bottom: none;
}
.contract__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}
.contract__name {
  flex: 1;
  min-width: 0;
  color: var(--app-text-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.contract__rate-value {
  width: 56px;
  text-align: right;
  color: var(--app-text-tertiary);
}
.contract__value {
  width: 120px;
  text-align: right;
  color: var(--app-text);
}
</style>
src/views/home/components/charts/HomeFinanceChart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
<template>
  <HomePanel title="应收应付统计" :loading="loading">
    <Echarts
      :chartStyle="{ width: '100%', height: '260px' }"
      :grid="grid"
      :series="series"
      :tooltip="tooltip"
      :xAxis="xAxis"
      :yAxis="yAxis"
      style="height: 260px"
    />
  </HomePanel>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import HomePanel from '../HomePanel.vue'
import { statisticsReceivablePayable } from '@/api/viewIndex'
const loading = ref(true)
const series = ref([
  {
    type: 'bar',
    barWidth: 14,
    data: [],
    label: { show: true, position: 'right', color: 'rgba(0,0,0,0.55)' }
  }
])
const grid = { left: '3%', right: '10%', bottom: '3%', top: 10, containLabel: true }
const tooltip = { trigger: 'axis', axisPointer: { type: 'shadow' } }
const xAxis = [{ type: 'value' }]
const yAxis = [{ type: 'category', data: ['应付账款', '应收账款'] }]
onMounted(() => {
  statisticsReceivablePayable({ type: 1 }).then((res) => {
    const d = res.data || {}
    series.value[0].data = [
      { value: d.payableMoney, itemStyle: { color: '#D97706', borderRadius: [0, 6, 6, 0] } },
      { value: d.receivableMoney, itemStyle: { color: '#2C51D9', borderRadius: [0, 6, 6, 0] } }
    ]
  }).finally(() => { loading.value = false })
})
</script>
src/views/home/components/charts/HomeInvoiceChart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
<template>
  <HomePanel title="回款与开票趋势" subtitle="近半年" :loading="loading">
    <Echarts
      :chartStyle="{ width: '100%', height: '270px' }"
      :grid="grid"
      :legend="legend"
      :series="series"
      :tooltip="tooltip"
      :xAxis="xAxis"
      :yAxis="yAxis"
      style="height: 270px"
    />
  </HomePanel>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
import Echarts from '@/components/Echarts/echarts.vue'
import HomePanel from '../HomePanel.vue'
import { getAmountHalfYear } from '@/api/viewIndex'
const loading = ref(true)
const legend = { show: true, top: 0, right: 0, itemWidth: 10, itemHeight: 10, data: ['开票', '回款'] }
const grid = { left: '3%', right: '4%', bottom: '3%', top: 34, containLabel: true }
const tooltip = { trigger: 'axis' }
const xAxis = ref([{
  type: 'category',
  boundaryGap: false,
  axisTick: { show: false },
  data: [],
  axisLabel: {
    interval: 0,
    formatter: (value) => value.replace(/~/g, '\n')
  }
}])
const yAxis = [{ type: 'value', splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)', type: 'dashed' } } }]
const area = (from, to) => ({
  color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
    { offset: 0, color: from },
    { offset: 1, color: to }
  ])
})
const series = ref([
  {
    name: '开票',
    type: 'line',
    smooth: true,
    symbolSize: 6,
    data: [],
    lineStyle: { width: 2, color: '#2C51D9' },
    itemStyle: { color: '#2C51D9' },
    areaStyle: area('rgba(44, 81, 217, 0.22)', 'rgba(44, 81, 217, 0.02)'),
    emphasis: { focus: 'series' }
  },
  {
    name: '回款',
    type: 'line',
    smooth: true,
    symbolSize: 6,
    data: [],
    lineStyle: { width: 2, color: '#0F9960' },
    itemStyle: { color: '#0F9960' },
    areaStyle: area('rgba(15, 153, 96, 0.18)', 'rgba(15, 153, 96, 0.02)'),
    emphasis: { focus: 'series' }
  }
])
onMounted(() => {
  getAmountHalfYear().then((res) => {
    const months = []
    const receipt = []
    const invoice = []
    ;(res.data || []).forEach((item) => {
      months.push(item.month)
      receipt.push(item.receiptAmount)
      invoice.push(item.invoiceAmount)
    })
    xAxis.value[0].data = months
    series.value[0].data = invoice
    series.value[1].data = receipt
  }).finally(() => { loading.value = false })
})
</script>
src/views/home/components/charts/HomeProcessChart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,289 @@
<template>
  <HomePanel title="工序生产统计" subtitle="投入 Â· æŠ¥åºŸ Â· äº§å‡º" :loading="loading">
    <template #extra>
      <el-button size="small" plain @click="openDialog">选择工序</el-button>
      <el-button size="small" plain @click="resetFilter">重置</el-button>
      <el-radio-group v-model="range" size="small" @change="refresh">
        <el-radio-button :value="1">日</el-radio-button>
        <el-radio-button :value="2">周</el-radio-button>
        <el-radio-button :value="3">月</el-radio-button>
      </el-radio-group>
    </template>
    <div class="process">
      <div class="process__chart">
        <Echarts
          :chartStyle="{ width: '100%', height: '100%' }"
          :grid="grid"
          :series="series"
          :tooltip="tooltip"
          :xAxis="xAxis"
          :yAxis="yAxis"
          style="height: 100%"
          @click="handleChartClick"
        />
      </div>
      <aside class="process__aside">
        <div class="process__legend">
          <span class="process__legend-item"><i class="dot" style="background: #2c51d9"></i>投入量</span>
          <span class="process__legend-item"><i class="dot" style="background: #d97706"></i>报废量</span>
          <span class="process__legend-item"><i class="dot" style="background: #0f9960"></i>产出量</span>
        </div>
        <div class="process__name">{{ aside.processName }}</div>
        <div class="process__stat">
          <span class="process__stat-label">累计总投入</span>
          <span class="process__stat-value app-num">{{ formatAmount(aside.totalInput) }}</span>
        </div>
        <div class="process__stat">
          <span class="process__stat-label">累计总报废</span>
          <span class="process__stat-value app-num">{{ formatAmount(aside.totalScrap) }}</span>
        </div>
        <div class="process__stat">
          <span class="process__stat-label">累计总产出</span>
          <span class="process__stat-value app-num">{{ formatAmount(aside.totalOutput) }}</span>
        </div>
      </aside>
    </div>
    <el-dialog v-model="dialogVisible" title="选择工序" width="500px" append-to-body :close-on-click-modal="false">
      <div class="dialog-body">
        <el-checkbox-group v-model="tempIds">
          <div class="process-grid">
            <el-checkbox v-for="item in options" :key="item.id" :label="item.id" border>
              {{ item.name }}
            </el-checkbox>
          </div>
        </el-checkbox-group>
      </div>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="confirmDialog">确认</el-button>
      </template>
    </el-dialog>
  </HomePanel>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import HomePanel from '../HomePanel.vue'
import { processDataProductionStatistics } from '@/api/viewIndex'
import { list } from '@/api/productionManagement/productionProcess'
const loading = ref(true)
const range = ref(1)
const chartData = ref([])
const activeIndex = ref(0)
const options = ref([])
const selectedIds = ref([])
const tempIds = ref([])
const dialogVisible = ref(false)
const xAxis = ref([
  {
    nameTextStyle: { color: 'rgba(0,0,0,0.35)', fontSize: 12 },
    axisLabel: { color: 'rgba(0,0,0,0.45)' },
    splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)', type: 'dashed' } }
  }
])
const yAxis = ref([
  {
    type: 'category',
    axisTick: { show: false },
    axisLine: { show: false },
    axisLabel: { color: 'rgba(0,0,0,0.55)' },
    data: []
  }
])
const grid = reactive({ left: 0, right: 60, top: 20, bottom: 20, containLabel: true })
const tooltip = reactive({
  trigger: 'axis',
  axisPointer: { type: 'shadow' },
  formatter: (params) => {
    const name = params?.[0]?.name ?? ''
    const lines = (Array.isArray(params) ? params : [])
      .map((p) => {
        const box = `<span style="display:inline-block;margin-right:6px;border-radius:2px;width:10px;height:10px;background:${p.color}"></span>`
        return `${box}${p.seriesName} <b style="float:right;">${Number(p.value || 0).toFixed(2)}</b>`
      })
      .join('<br/>')
    return `<div style="min-width:140px;"><div style="font-weight:700;margin-bottom:6px;">${name}</div>${lines}</div>`
  }
})
const series = computed(() => [
  {
    name: '投入量', type: 'bar', stack: 'total', barWidth: 18,
    itemStyle: { color: '#2C51D9', borderRadius: [6, 0, 0, 6] },
    data: chartData.value.map((i) => i.input)
  },
  {
    name: '报废量', type: 'bar', stack: 'total', barWidth: 18,
    itemStyle: { color: '#D97706' },
    data: chartData.value.map((i) => i.scrap)
  },
  {
    name: '产出量', type: 'bar', stack: 'total', barWidth: 18,
    itemStyle: { color: '#0F9960', borderRadius: [0, 6, 6, 0] },
    data: chartData.value.map((i) => i.output)
  }
])
const aside = computed(() => {
  const item = chartData.value[activeIndex.value] || {}
  return {
    processName: item.name || '暂无数据',
    totalInput: item.input || 0,
    totalScrap: item.scrap || 0,
    totalOutput: item.output || 0
  }
})
const formatAmount = (n) =>
  Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const refresh = () => {
  return processDataProductionStatistics({
    type: range.value,
    processIds: selectedIds.value.length > 0 ? selectedIds.value.join(',') : null
  }).then((res) => {
    chartData.value = (res.data || []).map((item) => ({
      name: item.processName,
      input: item.totalInput,
      scrap: item.totalScrap,
      output: item.totalOutput
    }))
    yAxis.value[0].data = chartData.value.map((i) => i.name)
    activeIndex.value = 0
  })
}
const openDialog = () => {
  tempIds.value = [...selectedIds.value]
  dialogVisible.value = true
}
const confirmDialog = () => {
  selectedIds.value = [...tempIds.value]
  dialogVisible.value = false
  refresh()
}
const resetFilter = () => {
  selectedIds.value = []
  tempIds.value = []
  refresh()
}
const handleChartClick = (params) => {
  if (params && params.dataIndex !== undefined) activeIndex.value = params.dataIndex
}
onMounted(() => {
  list().then((res) => { options.value = res.data || [] }).catch(() => {})
  Promise.all([refresh()]).finally(() => { loading.value = false })
})
</script>
<style scoped>
.process {
  display: flex;
  gap: 16px;
  height: 300px;
}
.process__chart {
  flex: 1;
  min-width: 0;
}
.process__aside {
  width: 190px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.process__legend {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  padding: 0 0 4px;
}
.process__legend-item {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 12px;
  color: var(--app-text-tertiary);
}
.dot {
  width: 8px;
  height: 8px;
  border-radius: 2px;
  display: inline-block;
}
.process__name {
  padding: 8px 12px;
  border-radius: var(--app-radius-sm);
  background: var(--app-primary-soft);
  color: var(--app-primary);
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.process__stat {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  border-radius: var(--app-radius-sm);
  background: var(--app-surface-hover);
}
.process__stat-label {
  font-size: 12px;
  color: var(--app-text-tertiary);
}
.process__stat-value {
  font-size: 14px;
  font-weight: 600;
  color: var(--app-text);
}
.dialog-body {
  max-height: 380px;
  overflow-y: auto;
}
.process-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
  gap: 12px;
}
.process-grid :deep(.el-checkbox.is-bordered) {
  margin-left: 0 !important;
  width: 100%;
}
@media (max-width: 1200px) {
  .process { flex-direction: column; height: auto; }
  .process__chart { height: 280px; }
  .process__aside { width: 100%; flex-direction: row; flex-wrap: wrap; }
  .process__aside > * { flex: 1; min-width: 150px; }
}
</style>
src/views/home/components/charts/HomeQualityChart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,122 @@
<template>
  <HomePanel title="质量统计" :loading="loading">
    <template #extra>
      <el-radio-group v-model="range" size="small" @change="refresh">
        <el-radio-button :value="1">周</el-radio-button>
        <el-radio-button :value="2">月</el-radio-button>
        <el-radio-button :value="3">季度</el-radio-button>
      </el-radio-group>
    </template>
    <div class="quality__cards">
      <div class="quality__mini">
        <span class="quality__mini-label">原材料已检测</span>
        <span class="quality__mini-value app-num">{{ total.supplierNum }}<i>ä»¶</i></span>
      </div>
      <div class="quality__mini">
        <span class="quality__mini-label">过程检验</span>
        <span class="quality__mini-value app-num">{{ total.processNum }}<i>ä»¶</i></span>
      </div>
      <div class="quality__mini">
        <span class="quality__mini-label">出厂已检</span>
        <span class="quality__mini-value app-num">{{ total.factoryNum }}<i>ä»¶</i></span>
      </div>
    </div>
    <Echarts
      :chartStyle="{ width: '100%', height: '230px' }"
      :grid="grid"
      :legend="legend"
      :series="series"
      :tooltip="tooltip"
      :xAxis="xAxis"
      :yAxis="yAxis"
      style="height: 230px"
    />
  </HomePanel>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import HomePanel from '../HomePanel.vue'
import { qualityInspectionStatistics } from '@/api/viewIndex'
const loading = ref(true)
const range = ref(1)
const total = reactive({ supplierNum: 0, processNum: 0, factoryNum: 0 })
const legend = { show: true, top: 0, right: 0, itemWidth: 10, itemHeight: 10, data: ['原材料不合格数', '过程不合格数', '出厂不合格数'] }
const grid = { left: '3%', right: '4%', bottom: '3%', top: 34, containLabel: true }
const tooltip = { trigger: 'axis', axisPointer: { type: 'shadow' } }
const xAxis = ref([{ type: 'category', axisTick: { show: false }, data: [] }])
const yAxis = [{ type: 'value' }]
const series = ref([
  { name: '原材料不合格数', type: 'bar', barGap: 0, barWidth: 10, itemStyle: { color: '#2C51D9', borderRadius: [3, 3, 0, 0] }, emphasis: { focus: 'series' }, data: [] },
  { name: '过程不合格数', type: 'bar', barWidth: 10, itemStyle: { color: '#D97706', borderRadius: [3, 3, 0, 0] }, emphasis: { focus: 'series' }, data: [] },
  { name: '出厂不合格数', type: 'bar', barWidth: 10, itemStyle: { color: '#0F9960', borderRadius: [3, 3, 0, 0] }, emphasis: { focus: 'series' }, data: [] }
])
const refresh = () => {
  return qualityInspectionStatistics({ type: range.value }).then((res) => {
    const d = res.data || {}
    xAxis.value[0].data = []
    series.value.forEach((s) => { s.data = [] })
    ;(d.item || []).forEach((item) => {
      xAxis.value[0].data.push(item.date)
      series.value[0].data.push(item.supplierNum)
      series.value[1].data.push(item.processNum)
      series.value[2].data.push(item.factoryNum)
    })
    total.supplierNum = d.supplierNum || 0
    total.processNum = d.processNum || 0
    total.factoryNum = d.factoryNum || 0
  })
}
onMounted(() => {
  Promise.all([refresh()]).finally(() => { loading.value = false })
})
</script>
<style scoped>
.quality__cards {
  display: flex;
  gap: 10px;
  margin-bottom: 12px;
}
.quality__mini {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px 12px;
  border-radius: var(--app-radius-sm);
  background: var(--app-surface-hover);
}
.quality__mini-label {
  font-size: 12px;
  color: var(--app-text-tertiary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.quality__mini-value {
  font-size: 16px;
  font-weight: 600;
  color: var(--app-text);
}
.quality__mini-value i {
  font-style: normal;
  font-size: 12px;
  font-weight: 400;
  color: var(--app-text-tertiary);
  margin-left: 3px;
}
</style>
src/views/index.vue
@@ -1,1263 +1,113 @@
<template>
  <div class="dashboard">
    <!-- é¡¶éƒ¨æ¨ªå‘两栏 -->
    <div class="dashboard-top">
      <!-- å·¦ï¼šä¼ä¸šä¿¡æ¯+三大数据卡片(上下排列) -->
      <div class="top-left">
        <div class="company-info">
          <!-- é¡¶éƒ¨é—®å€™æ¡ -->
          <div class="welcome-banner">
            <div class="welcome-title">
              <span class="welcome-user">{{ userStore.roleName || '系统管理员' }}</span>
              <span> æ‚¨å¥½ï¼ç¥æ‚¨å¼€å¿ƒæ¯ä¸€å¤©</span>
            </div>
            <div class="welcome-time">登录于: {{ userStore.currentLoginTime }}</div>
  <div class="workbench">
    <HomeWelcome
      :user-name="displayName"
      :role-name="userStore.roleName"
      :dept-name="userStore.deptName"
      :todo-count="todos.length"
      :today-inbound="business.todayInventoryNum || 0"
    />
    <HomeStats :business="business" />
    <div class="workbench__row workbench__row--main">
      <HomeProcessChart class="workbench__grow" />
      <HomeTodo class="workbench__side" :list="todos" :loading="todosLoading" />
          </div>
          <!-- ç”¨æˆ·ä¿¡æ¯å¡ç‰‡ -->
          <div class="user-card">
            <img :src="userStore.avatar" class="avatar" alt="" />
            <div class="user-card-main">
              <div class="user-name">{{ userStore.name }}</div>
              <div class="user-role">{{ userStore.roleName }}</div>
              <div class="user-meta">
                <span>{{ userStore.phoneNumber || '123456789' }}</span>
                <span class="sep">|</span>
                <span>{{ userStore.deptName || '组织架构' }}</span>
                <span class="sep">|</span>
                <span>{{ userStore.postName || '岗位名' }}</span>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div class="data-cards">
        <div class="data-card sales">
          <div class="data-title">销售数据</div>
          <div class="data-num">
            <div>
              <div class="data-desc">本月销售额/元</div>
              <div class="data-value">{{ businessInfo.monthSaleMoney }}</div>
            </div>
            <div>
              <div class="data-desc">未开票金额/元</div>
              <div class="data-value">{{ businessInfo.monthSaleHaveMoney }}</div>
            </div>
    <div class="workbench__row workbench__row--triple">
      <HomeContractChart />
      <HomeFinanceChart />
      <HomeQuickEntry />
          </div>
        </div>
        <div class="data-card purchase">
          <div class="data-title">采购数据</div>
          <div class="data-num">
            <div>
              <div class="data-desc">本月采购额/元</div>
              <div class="data-value">{{ businessInfo.monthPurchaseMoney }}</div>
            </div>
            <div>
              <div class="data-desc">待付款金额/元</div>
              <div class="data-value">{{ businessInfo.monthPurchaseHaveMoney }}</div>
            </div>
          </div>
        </div>
        <div class="data-card inventory">
          <div class="data-title">库存数据</div>
          <div class="data-num">
            <div>
              <div class="data-desc">当前库存总量/ä»¶</div>
              <div class="data-value">{{ businessInfo.inventoryNum }}</div>
            </div>
            <div>
              <div class="data-desc">今日入库/ä»¶</div>
              <div class="data-value">{{ businessInfo.todayInventoryNum }}</div>
            </div>
          </div>
        </div>
      </div>
      <!-- å³ï¼šå¾…办事项 -->
      <div class="todo-panel">
        <div class="section-title">待办事项</div>
        <ul class="todo-list" v-if="todoList.length > 0">
          <li v-for="item in todoList" :key="item.id">
            <div style="display: flex;flex-direction: column;justify-content: space-between;width: 100%;gap: 20px">
              <div style="display: flex;justify-content: space-between;align-items: center;">
                <div class="todo-title">待办编号:{{ item.approveId }}</div>
                <div class="todo-division">部门:{{ item.approveDeptName }}</div>
                <div class="todo-time">{{ item.approveTime }}</div>
              </div>
              <div class="todo-division">待办事由:{{ item.approveReason }}</div>
            </div>
          </li>
        </ul>
        <div v-else style="text-align: center">
          æš‚无数据
        </div>
      </div>
    </div>
    <div class="dashboard-row">
      <div class="main-panel process-panel">
        <div class="process-panel__header">
          <div class="section-title">工序数据生产统计明细</div>
          <div style="display: flex; gap: 10px; align-items: center;">
            <el-button type="primary" size="small" plain icon="Filter" @click="openProcessDialog">选择工序</el-button>
            <el-button type="info" size="small" plain icon="Refresh" @click="resetProcessFilter">重置</el-button>
            <el-radio-group v-model="processRange" size="small" @change="refreshProcessStats">
              <el-radio-button :value="1">日</el-radio-button>
              <el-radio-button :value="2">周</el-radio-button>
              <el-radio-button :value="3">月</el-radio-button>
            </el-radio-group>
          </div>
        </div>
        <div class="process-panel__body">
          <div class="process-panel__chart">
            <Echarts :chartStyle="{ width: '100%', height: '100%' }" :grid="processGrid" :series="processSeries"
              :tooltip="processTooltip" :xAxis="processXAxis" :yAxis="processYAxis" style="height: 100%"
              @click="handleChartClick" />
          </div>
          <div class="process-panel__aside">
            <div class="process-legend">
              <div class="process-legend__item">
                <span class="dot dot-blue"></span><span>投入量</span>
              </div>
              <div class="process-legend__item">
                <span class="dot dot-yellow"></span><span>报废量</span>
              </div>
              <div class="process-legend__item">
                <span class="dot dot-teal"></span><span>产出量</span>
              </div>
            </div>
            <div class="process-card process-card--name">{{ processAside.processName }}</div>
            <div class="process-card">
              <div class="process-card__label">累计总投入</div>
              <div class="process-card__value">{{ formatAmount(processAside.totalInput) }}
              </div>
            </div>
            <div class="process-card">
              <div class="process-card__label">累计总报废</div>
              <div class="process-card__value">{{ formatAmount(processAside.totalScrap) }}
              </div>
            </div>
            <div class="process-card">
              <div class="process-card__label">累计总产出</div>
              <div class="process-card__value">{{ formatAmount(processAside.totalOutput) }}
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <!-- å·¥åºé€‰æ‹©å¼¹çª— -->
    <el-dialog v-model="processDialogVisible" title="选择工序" width="500px" append-to-body>
      <div class="process-selection-wrapper">
        <el-checkbox-group v-model="tempProcessIds">
          <div class="process-grid">
            <el-checkbox v-for="item in processOptions" :key="item.id" :label="item.id" border>
              {{ item.name }}
            </el-checkbox>
          </div>
        </el-checkbox-group>
      </div>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="processDialogVisible = false">取消</el-button>
          <el-button type="primary" @click="handleProcessDialogConfirm">确认</el-button>
        </span>
      </template>
    </el-dialog>
    <!-- ä¸­éƒ¨æ¨ªå‘两栏 -->
    <div class="dashboard-row">
      <div class="main-panel">
        <div class="section-title">客户合同金额分析</div>
        <div class="contract-summary">
          <div class="contract-info">
            <img src="../assets/images/khtitle.png" alt="" style="width: 42px" />
            <div class="contract-card">
              <div class="contract-name">总合同金额(元)</div>
              <div class="contract-meta">
                <div class="main-amount">{{ sum }}</div>
                <div>周同比: <span class="up">{{ yny }}% </span> æ—¥çŽ¯æ¯”: <span class="up">{{ chain }}% </span></div>
              </div>
            </div>
          </div>
        </div>
        <div
          style="display: flex;align-items: center;gap: 20px;justify-content: space-evenly;height: 180px;margin-top: 20px">
          <div>
            <Echarts ref="chart" :legend="pieLegend" :chartStyle="chartStylePie" :series="materialPieSeries"
              :tooltip="pieTooltip"></Echarts>
          </div>
          <ul class="contract-list">
            <li v-for="item in materialPieSeries[0].data" :key="item.name">
              <div style="display: flex;align-items: center;justify-content: space-between;width: 100%">
                <div class="line" :style="{ color: item.itemStyle.color }">●{{ item.name }}</div>
                <div style="width: 70px">{{ item.rate }}%</div>
                <div>ï¿¥{{ item.value }}</div>
              </div>
            </li>
          </ul>
        </div>
      </div>
      <div class="main-panel">
        <div style="display: flex;justify-content: space-between;">
          <div class="section-title">应收应付统计</div>
          <!--                    <el-radio-group v-model="radio1" size="large" @change="statisticsReceivable">-->
          <!--                        <el-radio-button label="按周" :value="1" />-->
          <!--                        <el-radio-button label="按月" :value="2" />-->
          <!--                        <el-radio-button label="按季度" :value="3" />-->
          <!--                    </el-radio-group>-->
        </div>
        <Echarts ref="chart" :color="barColors2" :chartStyle="chartStyle" :grid="grid" :series="barSeries"
          :tooltip="tooltip" :xAxis="xAxis" :yAxis="yAxis" style="height: 260px"></Echarts>
      </div>
    </div>
    <!-- åº•部横向两栏 -->
    <div class="dashboard-row">
      <div class="main-panel">
        <div style="display: flex;justify-content: space-between;align-items: center;margin-bottom: 10px;">
          <div class="section-title" style="margin-bottom: 0;">质量统计</div>
          <el-radio-group v-model="qualityRange" size="small" @change="qualityStatisticsInfo">
            <el-radio-button :value="1">周</el-radio-button>
            <el-radio-button :value="2">月</el-radio-button>
            <el-radio-button :value="3">季度</el-radio-button>
          </el-radio-group>
        </div>
        <div class="quality-cards">
          <div class="quality-card one">原材料已检测数 <span>{{ qualityStatisticsObject.supplierNum }}ä»¶</span></div>
          <div class="quality-card two">过程检验数量 <span>{{ qualityStatisticsObject.processNum }}ä»¶</span></div>
          <div class="quality-card three">出厂已检数量 <span>{{ qualityStatisticsObject.factoryNum }}ä»¶</span></div>
        </div>
        <Echarts ref="chart" :chartStyle="chartStyle" :grid="grid" :legend="barLegend" :series="barSeries1"
          :tooltip="tooltip" :xAxis="xAxis1" :yAxis="yAxis1" style="height: 260px"></Echarts>
      </div>
      <div class="main-panel">
        <div class="section-title">回款与开票分析</div>
        <Echarts ref="invoiceChart" :chartStyle="chartStyle" :grid="grid" :legend="lineLegend" :series="lineSeries"
          :tooltip="tooltipLine" :xAxis="xAxis2" :yAxis="yAxis2" style="height: 270px;" />
      </div>
    <div class="workbench__row workbench__row--double">
      <HomeQualityChart />
      <HomeInvoiceChart />
    </div>
  </div>
</template>
<script setup>
import { ref, onMounted, computed, reactive } from 'vue'
import Echarts from "@/components/Echarts/echarts.vue";
import * as echarts from 'echarts';
import useUserStore from "@/store/modules/user.js";
import {
  analysisCustomerContractAmounts, getAmountHalfYear,
  getBusiness,
  homeTodos,
  processDataProductionStatistics,
  statisticsReceivablePayable,
  qualityInspectionStatistics
} from "@/api/viewIndex.js";
import { list } from '@/api/productionManagement/productionProcess';
import { ref, computed, onMounted } from 'vue'
import useUserStore from '@/store/modules/user'
import { getBusiness, homeTodos } from '@/api/viewIndex'
import HomeWelcome from './home/components/HomeWelcome.vue'
import HomeStats from './home/components/HomeStats.vue'
import HomeTodo from './home/components/HomeTodo.vue'
import HomeQuickEntry from './home/components/HomeQuickEntry.vue'
import HomeProcessChart from './home/components/charts/HomeProcessChart.vue'
import HomeContractChart from './home/components/charts/HomeContractChart.vue'
import HomeFinanceChart from './home/components/charts/HomeFinanceChart.vue'
import HomeQualityChart from './home/components/charts/HomeQualityChart.vue'
import HomeInvoiceChart from './home/components/charts/HomeInvoiceChart.vue'
const userStore = useUserStore()
const processOptions = ref([])
const selectedProcessIds = ref([])
const tempProcessIds = ref([])
const processDialogVisible = ref(false)
const activeProcessIndex = ref(0)
const business = ref({})
const todos = ref([])
const todosLoading = ref(true)
const businessInfo = ref({
  inventoryNum: 0,
  monthPurchaseHaveMoney: 0,
  monthPurchaseMoney: 0,
  monthSaleHaveMoney: 0,
  monthSaleMoney: 0,
  todayInventoryNum: 0,
})
const qualityStatisticsObject = ref({
  supplierNum: 0,
  processNum: 0,
  factoryNum: 0,
})
const sum = ref(0)
const yny = ref(0)
const chain = ref(0)
const pieLegend = reactive({
  show: false,
})
const barSeries = ref([
  {
    type: 'bar',
    data: [],
    label: {
      show: true,
    }
  },
])
const barSeries1 = ref([
  {
    name: '原材料不合格数',
    type: 'bar',
    barGap: 0,
    emphasis: {
      focus: 'series'
    },
    data: []
  },
  {
    name: '过程不合格数',
    type: 'bar',
    emphasis: {
      focus: 'series'
    },
    data: []
  },
  {
    name: '出厂不合格数',
    type: 'bar',
    emphasis: {
      focus: 'series'
    },
    data: []
  },
])
const chartStyle = {
  width: '100%',
  height: '100%' // è®¾ç½®å›¾è¡¨å®¹å™¨çš„高度
}
const chartStylePie = {
  width: '140%',
  height: '140%' // è®¾ç½®å›¾è¡¨å®¹å™¨çš„高度
}
const grid = {
  left: '3%',
  right: '4%',
  bottom: '3%',
  containLabel: true
}
const barLegend = {
  show: true,
  data: ['原材料不合格数', '过程不合格数', '出厂不合格数']
}
const barLegend1 = {
  show: true,
  data: ['预付账款', '应付账款', '预收账款', '应收账款']
}
const lineLegend = {
  show: true,
  data: ['开票', '回款']
}
const tooltip = {
  trigger: 'axis',
  axisPointer: {
    type: 'shadow'
  }
}
const xAxis = [{
  type: 'value',
}]
const xAxis1 = ref([{
  type: 'category',
  axisTick: { show: false },
  data: []
}])
const yAxis = [{
  type: 'category',
  data: ['应付账款', '应收账款',]
}]
const yAxis1 = [{
  type: 'value'
}]
const pieTooltip = reactive({
  trigger: 'item',
  formatter: function (params) {
    // åŠ¨æ€ç”Ÿæˆæç¤ºä¿¡æ¯ï¼ŒåŸºäºŽæ•°æ®é¡¹çš„ name å±žæ€§
    const description = params.name === '本月回款金额' ? '本月回款金额' : '应收款金额';
    return `${description} ${formatNumber(params.value)}元 ${params.percent}%`;
  },
  position: 'right'
})
const materialPieSeries = ref([
  {
    type: 'pie',
    radius: ['66%', '90%'],
    avoidLabelOverlap: false,
    itemStyle: {
      borderColor: '#fff',
      borderWidth: 2
    },
    label: {
      show: false
    },
    data: []
  }
])
const lineSeries = ref([
  {
    type: 'line',
    data: [],
    label: {
      show: true
    },
    showSymbol: true, // æ˜¾ç¤ºåœ†ç‚¹
  },
])
const tooltipLine = {
  trigger: 'axis',
}
const yAxis2 = ref([
  {
    type: 'value',
  }
])
const xAxis2 = ref([
  {
    type: 'category',
    data: [],
    axisLabel: {
      interval: 0,
      formatter: function (value) {
        return value.replace(/~/g, '\n');
      },
    }
  }
])
// å¾…办事项
const todoList = ref([])
const radio1 = ref(1)
const qualityRange = ref(1)
// å›¾è¡¨å¼•用
const barChart = ref(null)
const lineChart = ref(null)
const barColors2 = ['#5181DB', '#D369E0', '#F2CA6D', '#60CCA8']
// éšæœºé¢œè‰²ç”Ÿæˆå‡½æ•°
const getRandomColor = () => {
  return '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');
}
const displayName = computed(() => userStore.nickName || userStore.name || '用户')
onMounted(() => {
  getBusinessData()
  analysisCustomer()
  todoInfoS()
  statisticsReceivable()
  qualityStatisticsInfo()
  getAmountHalfYearNum()
  getProcessList()
})
// æ•°æ®ç»Ÿè®¡
const getBusinessData = () => {
  getBusiness().then((res) => {
    businessInfo.value = { ...res.data }
  })
}
// åˆåŒé‡‘额
const analysisCustomer = () => {
  analysisCustomerContractAmounts().then((res) => {
    sum.value = res.data.sum
    yny.value = res.data.yny
    chain.value = res.data.chain
    // ä¸ºæ¯ä¸ªæ•°æ®é¡¹åˆ†é…éšæœºé¢œè‰²
    materialPieSeries.value[0].data = res.data.item.map(item => ({
      ...item,
      itemStyle: { color: getRandomColor() }
    }))
  })
}
// å¾…办事项
const todoInfoS = () => {
    business.value = res.data || {}
  }).catch(() => {})
  homeTodos().then((res) => {
    todoList.value = res.data
    todos.value = res.data || []
  }).catch(() => {}).finally(() => {
    todosLoading.value = false
  })
}
// èŽ·å–å·¥åºåˆ—è¡¨
const getProcessList = () => {
  list().then(res => {
    processOptions.value = res.data
  })
}
const openProcessDialog = () => {
  tempProcessIds.value = [...selectedProcessIds.value]
  processDialogVisible.value = true
}
const handleProcessDialogConfirm = () => {
  selectedProcessIds.value = [...tempProcessIds.value]
  processDialogVisible.value = false
  refreshProcessStats()
}
const resetProcessFilter = () => {
  selectedProcessIds.value = []
  tempProcessIds.value = []
  refreshProcessStats()
}
const handleChartClick = (params) => {
  if (params && params.dataIndex !== undefined) {
    activeProcessIndex.value = params.dataIndex
  }
}
// åº”付应收统计
const statisticsReceivable = () => {
  statisticsReceivablePayable({ type: radio1.value }).then((res) => {
    barSeries.value[0].data = [
      // { value: res.data.prepayMoney, itemStyle: { color: barColors2[0] } },
      { value: res.data.payableMoney, itemStyle: { color: barColors2[0] } },
      // { value: res.data.advanceMoney, itemStyle: { color: barColors2[2] } },
      { value: res.data.receivableMoney, itemStyle: { color: barColors2[1] } }
    ]
  })
}
// è´¨æ£€ç»Ÿè®¡
const qualityStatisticsInfo = () => {
  qualityInspectionStatistics({ type: qualityRange.value }).then((res) => {
    xAxis1.value[0].data = []
    barSeries1.value[0].data = []
    barSeries1.value[1].data = []
    barSeries1.value[2].data = []
    res.data.item.forEach(item => {
      xAxis1.value[0].data.push(item.date)
      barSeries1.value[0].data.push(item.supplierNum)
      barSeries1.value[1].data.push(item.processNum)
      barSeries1.value[2].data.push(item.factoryNum)
    })
    qualityStatisticsObject.value.supplierNum = res.data.supplierNum
    qualityStatisticsObject.value.processNum = res.data.processNum
    qualityStatisticsObject.value.factoryNum = res.data.factoryNum
  })
}
const getAmountHalfYearNum = async () => {
  const res = await getAmountHalfYear()
  console.log(res)
  const monthName = []
  const receiptAmount = []
  const invoiceAmount = []
  res.data.forEach(item => {
    monthName.push(item.month)
    receiptAmount.push(item.receiptAmount)
    invoiceAmount.push(item.invoiceAmount)
  })
  // æ­£ç¡®å“åº”式赋值:创建新的 xAxis å’Œ series å¯¹è±¡
  xAxis2.value[0].data = monthName
  xAxis2.value[0].data = monthName.map(item => item.replace(/~/g, '\n~'));
  lineSeries.value = [
    {
      name: '开票',
      type: 'line',
      data: invoiceAmount,
      stack: 'Total',
      areaStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          {
            offset: 0,
            color: 'rgba(131, 207, 255, 1)'
          },
          {
            offset: 1,
            color: 'rgba(186, 228, 255, 1)'
          }
        ])
      },
      itemStyle: {
        color: '#2D99FF',
        borderColor: '#2D99FF'
      },
      emphasis: {
        focus: 'series'
      },
      lineStyle: {
        width: 0
      },
      showSymbol: true,
    },
    {
      name: '回款',
      type: 'line',
      data: receiptAmount,
      stack: 'Total',
      lineStyle: {
        width: 0
      },
      itemStyle: {
        color: '#83CFFF',
        borderColor: '#83CFFF'
      },
      showSymbol: true,
      areaStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          {
            offset: 0,
            color: 'rgba(54, 153, 255, 1)'
          },
          {
            offset: 1,
            color: 'rgba(89, 169, 254, 1)'
          }
        ])
      },
      emphasis: {
        focus: 'series'
      },
    }
  ]
}
// å·¥åºæ•°æ®ç”Ÿäº§ç»Ÿè®¡æ˜Žç»†ï¼ˆå‡æ•°æ® + å›¾è¡¨ï¼‰
const processRange = ref(1)
const processChartData = ref([])
const processXAxis = ref([
  {
    nameTextStyle: { color: 'rgba(0,0,0,0.35)', fontSize: 12 },
    axisLabel: { color: 'rgba(0,0,0,0.35)' },
    splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)', type: 'dashed' } },
  },
])
const processYAxis = ref([
  {
    type: 'category',
    axisTick: { show: false },
    axisLine: { show: false },
    axisLabel: { color: 'rgba(0,0,0,0.45)' },
    data: [],
  },
])
const processGrid = reactive({ left: 0, right: 100, top: 30, bottom: 20, containLabel: true })
const processTooltip = reactive({
  trigger: 'axis',
  axisPointer: { type: 'shadow' },
  formatter: (params) => {
    const name = params?.[0]?.name ?? ''
    const list = Array.isArray(params) ? params : []
    const lines = list
      .map((p) => {
        const colorBox = `<span style="display:inline-block;margin-right:6px;border-radius:2px;width:10px;height:10px;background:${p.color}"></span>`
        return `${colorBox}${p.seriesName} <b style="float:right;">${Number(p.value || 0).toFixed(2)}</b>`
      })
      .join('<br/>')
    return `<div style="min-width:140px;"><div style="font-weight:700;margin-bottom:6px;">${name}</div>${lines}</div>`
  },
})
const processSeries = computed(() => {
  const input = processChartData.value.map((i) => i.input)
  const scrap = processChartData.value.map((i) => i.scrap)
  const output = processChartData.value.map((i) => i.output)
  return [
    {
      name: '投入量',
      type: 'bar',
      stack: 'total',
      barWidth: 22,
      itemStyle: { color: '#1E5BFF', borderRadius: [6, 0, 0, 6] },
      data: input,
    },
    {
      name: '报废量',
      type: 'bar',
      stack: 'total',
      barWidth: 22,
      itemStyle: { color: '#F7B500' },
      data: scrap,
    },
    {
      name: '产出量',
      type: 'bar',
      stack: 'total',
      barWidth: 22,
      itemStyle: { color: '#19C6C6', borderRadius: [0, 6, 6, 0] },
      data: output,
    },
  ]
})
const processAside = computed(() => {
  const list = processChartData.value
  const item = list[activeProcessIndex.value] || {}
  return {
    processName: item.name || '暂无数据',
    totalInput: item.input || 0,
    totalScrap: item.scrap || 0,
    totalOutput: item.output || 0,
  }
})
const formatAmount = (n) => {
  const num = Number(n || 0)
  return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const refreshProcessStats = () => {
  processDataProductionStatistics({
    type: processRange.value,
    processIds: selectedProcessIds.value.length > 0 ? selectedProcessIds.value.join(',') : null
  }).then(res => {
    processChartData.value = res.data.map(item => ({
      name: item.processName,
      input: item.totalInput,
      scrap: item.totalScrap,
      output: item.totalOutput
    }))
    processYAxis.value[0].data = processChartData.value.map((i) => i.name)
    activeProcessIndex.value = 0
  })
}
onMounted(() => {
  getBusinessData()
  analysisCustomer()
  todoInfoS()
  statisticsReceivable()
  qualityStatisticsInfo()
  getAmountHalfYearNum()
  refreshProcessStats()
})
</script>
<style scoped>
.dashboard {
  background: #f5f7fa;
  min-height: 100vh;
  padding: 20px;
.workbench {
  display: flex;
  flex-direction: column;
  gap: var(--app-gap);
  min-height: 100%;
  padding: var(--app-gap);
  box-sizing: border-box;
  background: var(--app-bg);
}
.dashboard-top {
  display: flex;
  gap: 20px;
  margin-bottom: 20px;
  align-items: flex-start;
  justify-content: space-evenly;
}
.company-info {
  padding: 0;
  overflow: hidden;
  border-radius: 12px;
  background: #fff;
  height: 100%;
}
.welcome-banner {
  padding: 10px 10px;
  background: linear-gradient(135deg, rgba(229, 240, 255, 0.9), rgba(214, 232, 255, 0.7), rgba(207, 236, 255, 0.9));
}
.welcome-title {
  font-size: 18px;
  font-weight: 700;
  color: #222;
  line-height: 1.3;
}
.welcome-user {
  margin-right: 6px;
}
.welcome-time {
  margin-top: 10px;
  font-size: 16px;
  color: rgba(0, 0, 0, 0.55);
}
.user-card {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 18px 22px;
}
.user-card-main {
  display: flex;
  flex-direction: column;
  gap: 5px;
  min-width: 0;
}
.user-name {
  font-size: 16px;
  font-weight: bold;
  color: #111;
  letter-spacing: 1px;
}
.user-role {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 20px;
  padding: 5px 10px;
  background: rgba(245, 246, 248, 1);
  color: #333;
  width: fit-content;
  font-weight: 600;
}
.user-meta {
  font-size: 12px;
  color: rgba(0, 0, 0, 0.55);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.user-meta .sep {
  margin: 0 10px;
  color: rgba(0, 0, 0, 0.25);
}
.avatar {
  width: 90px;
  height: 90px;
  border-radius: 50%;
  object-fit: cover;
  flex: 0 0 auto;
}
.data-cards {
  width: 50%;
  display: flex;
  gap: 16px;
  justify-content: flex-start;
  background: #ffffff;
  border-radius: 12px;
  padding: 20px;
}
.data-title {
  font-weight: 700;
  font-size: 26px;
  color: #FFFFFF;
}
.data-num {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 20px;
}
.data-card {
  background: #fff;
  border-radius: 12px;
  padding: 14px 10px 10px 10px;
  min-width: 160px;
  box-shadow: 0 2px 8px #eee;
  display: flex;
  flex-direction: column;
  width: 32%;
  height: 140px;
}
.data-card.sales {
  background-image: url("../assets/images/xioashoushuju.png");
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.data-card.purchase {
  background-image: url("../assets/images/caigou.png");
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.data-card.inventory {
  background-image: url("../assets/images/kucun.png");
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.data-desc {
  font-weight: 500;
  font-size: 13px;
  color: #FFFFFF;
}
.data-value {
  font-size: 18px;
  font-weight: 500;
  margin: 10px 0;
  color: #FFFFFF;
}
.top-left {
  display: flex;
  flex-direction: column;
  gap: 20px;
  height: 180px;
  width: 20%;
}
.todo-panel {
  background: #fff;
  border-radius: 12px;
  padding: 20px;
  height: 180px;
  width: 30%;
}
.todo-list {
  height: 100px;
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: 15px;
  overflow-y: auto;
}
.todo-list li {
  border-radius: 8px;
  margin-bottom: 12px;
  padding: 8px 20px;
  height: 74px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: rgba(225, 227, 250, 0.62);
}
.todo-title {
  font-weight: 400;
  font-size: 12px;
  color: #000000;
  position: relative;
}
.todo-title::before {
  content: '';
  /* å¿…需,表示这里有一个内容 */
  position: absolute;
  left: -10px;
  /* å®šä½åˆ°å·¦ä¾§ */
  top: 50%;
  /* åž‚直居中 */
  transform: translateY(-50%);
  /* å¾®è°ƒåž‚直居中 */
  width: 6px;
  /* åœ†çš„直径 */
  height: 6px;
  /* åœ†çš„直径 */
  background: #498CEB;
  border-radius: 50%;
  /* è®©å…¶å˜æˆåœ†å½¢ */
}
.todo-division {
  font-weight: 400;
  font-size: 12px;
  color: #000000;
}
.todo-time {
  font-weight: 400;
  font-size: 12px;
  color: #000000;
}
.todo-meta {
  color: #888;
  font-size: 13px;
}
.dashboard-row {
  display: flex;
  gap: 20px;
  margin-bottom: 20px;
}
.main-panel {
  background: #fff;
  border-radius: 12px;
  padding: 20px;
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}
.section-title {
  position: relative;
  font-size: 18px;
  color: #333;
  padding-left: 10px;
  margin-bottom: 10px;
  font-weight: 700;
}
.section-title::before {
  position: absolute;
  left: 0;
  top: 4px;
  content: '';
  width: 4px;
  height: 18px;
  background-color: #002FA7;
  border-radius: 2px;
}
.contract-info {
  display: flex;
  align-items: center;
  gap: 20px;
  height: 90px;
  background: rgba(245, 245, 245, 0.59);
  width: 100%;
  border-radius: 10px;
  padding: 10px 30px;
}
.contract-summary {
  display: flex;
  align-items: center;
  gap: 30px;
}
.contract-card {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.contract-name {
  font-weight: 400;
  font-size: 14px;
  color: #050505;
}
.contract-meta {
  display: flex;
  align-items: center;
  width: 100%;
  gap: 80px;
}
.main-amount {
  font-size: 24px;
  color: rgba(51, 50, 50, 0.85);
}
.up {
  color: #e57373;
}
.contract-list {
  margin-top: 16px;
  font-size: 14px;
  color: #666;
  list-style: none;
  padding: 0;
  height: 190px;
  overflow-y: auto;
  width: 460px;
}
.line {
  position: relative;
  width: 230px;
}
.line::after {
  content: '';
  position: absolute;
  right: 2px;
  top: 0;
  bottom: 0;
  width: 1px;
  background-color: #C9C5C5;
  border-radius: 2px;
}
.contract-list li {
  margin-top: 10px;
}
.quality-cards {
  display: flex;
  gap: 12px;
  margin-bottom: 12px;
}
.quality-card {
  border-radius: 8px;
  padding: 15px 10px 10px 50px;
  font-weight: 400;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.67);
  width: 236px;
  height: 49px;
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.quality-card.one {
  background-image: url("../assets/images/yuancailiao.png");
}
.quality-card.two {
  background-image: url("../assets/images/guocheng.png");
}
.quality-card.three {
  background-image: url("../assets/images/chuchang.png");
}
.quality-card span {
  color: #4fc3f7;
  font-weight: bold;
  margin-left: 6px;
}
.chart {
  width: 100%;
  height: 220px;
  margin-top: 10px;
}
.process-panel {
  padding-bottom: 10px;
}
.process-panel__header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.process-panel__body {
  display: flex;
  gap: 24px;
  align-items: stretch;
  margin-top: 10px;
}
.process-panel__chart {
  flex: 1;
  min-width: 0;
  padding: 6px 0;
}
.process-panel__aside {
  width: 260px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.process-legend {
  display: flex;
  flex-direction: column;
  gap: 10px;
  align-items: flex-start;
  padding: 8px 6px;
}
.process-legend__item {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.55);
}
.dot {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  display: inline-block;
}
.dot-blue {
  background: #1E5BFF;
}
.dot-yellow {
  background: #F7B500;
}
.dot-teal {
  background: #19C6C6;
}
.process-card {
  background: rgba(245, 247, 250, 0.9);
  border-radius: 10px;
  padding: 16px 16px;
}
.process-card--name {
  background: rgba(235, 242, 255, 1);
  color: #1E5BFF;
  font-weight: 800;
  font-size: 14px;
}
.process-card__label {
  font-size: 13px;
  color: rgba(0, 0, 0, 0.55);
  margin-bottom: 10px;
}
.process-card__value {
  font-size: 24px;
  font-weight: 800;
  color: rgba(0, 0, 0, 0.8);
}
.process-card__value .unit {
  font-size: 12px;
  font-weight: 600;
  color: rgba(0, 0, 0, 0.45);
  margin-left: 6px;
}
@media (max-width: 1200px) {
  .process-panel__body {
    flex-direction: column;
  }
  .process-panel__aside {
    width: 100%;
    flex-direction: row;
    flex-wrap: wrap;
  }
  .process-card {
    flex: 1;
    min-width: 220px;
  }
}
.process-selection-wrapper {
  max-height: 400px;
  overflow-y: auto;
  padding: 10px;
}
.process-grid {
.workbench__row {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
  gap: 12px;
  gap: var(--app-gap);
  min-width: 0;
}
:deep(.el-checkbox.is-bordered) {
  margin-left: 0 !important;
  width: 100%;
.workbench__row--main {
  grid-template-columns: minmax(0, 1fr) 380px;
}
.workbench__row--triple {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}
.workbench__row--double {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workbench__grow,
.workbench__side {
  min-width: 0;
}
@media (max-width: 1440px) {
  .workbench__row--main { grid-template-columns: 1fr; }
  .workbench__row--triple { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 1024px) {
  .workbench__row--triple,
  .workbench__row--double { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
  .workbench { padding: 12px; }
}
</style>
src/views/login.vue
@@ -1,78 +1,89 @@
<template>
  <div class="login">
    <el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form">
      <h3 class="title">{{ title }}</h3>
      <el-divider />
    <LoginBrand />
    <main class="login-panel">
      <div class="login-card">
        <div class="login-card__header">
          <img :src="logo" alt="健齿齿科器材" class="login-card__logo" />
          <h2 class="login-card__title">欢迎登录</h2>
          <p class="login-card__subtitle">开启您的数字化工作台</p>
        </div>
        <el-form ref="loginRef" :model="loginForm" :rules="loginRules">
      <el-form-item prop="username">
        <el-input
          v-model="loginForm.username"
          type="text"
          size="large"
              maxlength="30"
          auto-complete="off"
          placeholder="账号"
              placeholder="请输入用户名"
              @keyup.enter="handleLogin"
        >
          <template #prefix><el-icon><User /></el-icon></template>
        </el-input>
      </el-form-item>
      <el-form-item prop="password">
        <el-input
          v-model="loginForm.password"
          type="password"
          size="large"
              maxlength="20"
          auto-complete="off"
          placeholder="密码"
              placeholder="请输入密码"
          show-password
          @keyup.enter="handleLogin"
        >
          <template #prefix><svg-icon icon-class="password" class="el-input__icon input-icon" /></template>
              <template #prefix><el-icon><Lock /></el-icon></template>
        </el-input>
      </el-form-item>
<!--      <el-form-item prop="code" v-if="captchaEnabled">-->
<!--        <el-input-->
<!--          v-model="loginForm.code"-->
<!--          size="large"-->
<!--          auto-complete="off"-->
<!--          placeholder="验证码"-->
<!--          style="width: 63%"-->
<!--          @keyup.enter="handleLogin"-->
<!--        >-->
<!--          <template #prefix><svg-icon icon-class="validCode" class="el-input__icon input-icon" /></template>-->
<!--        </el-input>-->
<!--        <div class="login-code">-->
<!--          <img :src="codeUrl" @click="getCode" class="login-code-img"/>-->
<!--        </div>-->
<!--      </el-form-item>-->
      <el-form-item style="width:100%;">
          <div class="login-card__options">
            <el-checkbox v-model="loginForm.rememberMe">记住账号</el-checkbox>
            <span class="login-card__link" @click="handleForgot">忘记密码?</span>
          </div>
        <el-button
          :loading="loading"
          size="large"
          type="primary"
          style="width:100%;"
            class="login-btn"
          @click.prevent="handleLogin"
        >
          <span v-if="!loading">登 å½•</span>
          <span v-else>登 å½• ä¸­...</span>
        </el-button>
        <div style="float: right;" v-if="register">
          <router-link class="link-type" :to="'/register'">立即注册</router-link>
        </div>
      </el-form-item>
      <el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>
    </el-form>
    <!--  åº•部  -->
<!--    <div class="el-login-footer">-->
<!--      <span>Copyright Â© 2018-2025 ruoyi.vip All Rights Reserved.</span>-->
<!--    </div>-->
        <div class="login-card__others">
          <div class="login-card__divider">
            <span>其他登录方式</span>
          </div>
          <div class="login-card__wecom" @click="handleWecom">
            <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
              <path fill="#2C51D9" d="M12 3C7.6 3 4 5.9 4 9.5c0 2 1.1 3.8 2.9 5l-.7 2.1 2.4-1.2c1 .3 2.1.5 3.4.5 4.4 0 8-2.9 8-6.4S16.4 3 12 3z"/>
              <circle cx="9" cy="9.5" r="1" fill="#fff"/>
              <circle cx="15" cy="9.5" r="1" fill="#fff"/>
            </svg>
            <span>企业微信登录</span>
          </div>
        </div>
        <p class="login-card__foot">健齿科技 Â· æœåŠ¡å…¨çƒ</p>
      </div>
    </main>
  </div>
</template>
<script setup>
import {getCodeImg} from "@/api/login"
import Cookies from "js-cookie"
import { encrypt, decrypt } from "@/utils/jsencrypt"
import useUserStore from '@/store/modules/user'
import LoginBrand from './login/components/LoginBrand.vue'
import logo from '@/assets/login/JCCKLogo.png'
const title = import.meta.env.VITE_APP_TITLE
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
@@ -87,15 +98,9 @@
const loginRules = {
  username: [{ required: true, trigger: "blur", message: "请输入您的账号" }],
  password: [{ required: true, trigger: "blur", message: "请输入您的密码" }],
  // code: [{ required: true, trigger: "change", message: "请输入验证码" }]
}
const codeUrl = ref("")
const loading = ref(false)
// éªŒè¯ç å¼€å…³
const captchaEnabled = ref(true)
// æ³¨å†Œå¼€å…³
const register = ref(false)
const redirect = ref(undefined)
watch(route, (newRoute) => {
@@ -121,23 +126,17 @@
        router.push({ path: redirect.value || "/", query: otherQueryParams })
      }).catch(() => {
        loading.value = false
        // é‡æ–°èŽ·å–éªŒè¯ç 
        if (captchaEnabled.value) {
          getCode()
        }
      })
    }
  })
}
function getCode() {
  getCodeImg().then(res => {
    captchaEnabled.value = res.captchaEnabled === undefined ? true : res.captchaEnabled
    if (captchaEnabled.value) {
      codeUrl.value = "data:image/gif;base64," + res.img
      loginForm.value.uuid = res.uuid
function handleForgot() {
  proxy.$modal.msg("请联系系统管理员重置密码")
    }
  })
function handleWecom() {
  proxy.$modal.msg("企业微信登录暂未开放")
}
function getCookie() {
@@ -145,88 +144,189 @@
  const password = Cookies.get("password")
  const rememberMe = Cookies.get("rememberMe")
  loginForm.value = {
    ...loginForm.value,
    username: username === undefined ? loginForm.value.username : username,
    password: password === undefined ? loginForm.value.password : decrypt(password),
    rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
  }
}
getCode()
getCookie()
</script>
<style lang='scss' scoped>
.login {
  display: flex;
  height: 100%;
  background-image: url("../assets/images/login-background.png");
  background-size: cover;
  position: relative;
}
.title {
  margin: 20px auto 14px auto;
  text-align: center;
  color: #2C51D9;
  font-size: 28px;
  font-weight: 700;
  min-height: 100vh;
  background: var(--app-bg, #f4f6f9);
}
.login-form {
  position: absolute;
  top: 50%;
  right: 15%;
  transform: translate(0, -50%);
  border-radius: 6px;
  background: #ffffff;
  width: 420px;
  height: 500px;
  padding: 40px;
  z-index: 1;
    box-shadow: 0 0 5px 1px #ccc;
  .el-input {
    height: 40px;
    input {
      height: 40px;
.login-panel {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: linear-gradient(180deg, #f6f8fd 0%, #eef2f9 100%);
    }
  }
  .input-icon {
    height: 39px;
    width: 14px;
    margin-left: 0px;
  }
}
.login-tip {
  font-size: 13px;
  text-align: center;
  color: #bfbfbf;
}
.login-code {
  width: 33%;
  height: 40px;
  float: right;
  img {
    cursor: pointer;
    vertical-align: middle;
  }
}
.el-login-footer {
  height: 40px;
  line-height: 40px;
  position: fixed;
  bottom: 0;
.login-card {
  width: 100%;
  max-width: 400px;
  padding: 40px 44px 28px;
  box-sizing: border-box;
  background: #fff;
  border-radius: var(--app-radius-lg, 14px);
  box-shadow: 0 12px 40px rgba(16, 42, 96, 0.08);
  .el-form-item {
    margin-bottom: 22px;
  }
}
.login-card__header {
  text-align: center;
  color: #fff;
  font-family: Arial;
  font-size: 12px;
  letter-spacing: 1px;
  margin-bottom: 30px;
}
.login-code-img {
.login-card__logo {
  height: 40px;
  padding-left: 12px;
  object-fit: contain;
}
:deep() {
  .el-form-item--default {
    margin-bottom: 36px;
.login-card__title {
  margin: 18px 0 0;
  font-size: 22px;
  font-weight: 700;
  color: var(--app-text, #1d2129);
  letter-spacing: 2px;
}
.login-card__subtitle {
  margin: 8px 0 0;
  font-size: 13px;
  color: var(--app-text-tertiary, #86909c);
}
.login-card__options {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin: -4px 0 20px;
  font-size: 14px;
}
.login-card__link {
  color: var(--app-primary, #2c51d9);
  font-size: 14px;
  cursor: pointer;
  transition: color var(--app-transition, 0.2s);
  &:hover {
    color: var(--app-primary-hover, #4a6cf0);
  }
}
.login-btn {
  width: 100%;
  height: 46px;
  border: none;
  border-radius: 10px;
  font-size: 16px;
  font-weight: 600;
  letter-spacing: 6px;
  background: linear-gradient(90deg, #2c51d9 0%, #4a6cf0 100%);
  box-shadow: 0 8px 20px rgba(44, 81, 217, 0.28);
  transition: filter var(--app-transition, 0.2s), transform var(--app-transition, 0.2s);
  &:hover,
  &:focus {
    filter: brightness(1.08);
  }
  &:active {
    transform: translateY(1px);
  }
}
.login-card__others {
  margin-top: 26px;
}
.login-card__divider {
  position: relative;
  text-align: center;
  font-size: 12px;
  color: var(--app-text-tertiary, #86909c);
  &::before {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    top: 50%;
    height: 1px;
    background: var(--app-border, #e8ecf1);
  }
  span {
    position: relative;
    padding: 0 12px;
    background: #fff;
  }
}
.login-card__wecom {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin-top: 18px;
  font-size: 14px;
  color: var(--app-text-secondary, #4e5969);
  cursor: pointer;
  transition: color var(--app-transition, 0.2s);
  &:hover {
    color: var(--app-primary, #2c51d9);
  }
}
.login-card__foot {
  margin: 26px 0 0;
  text-align: center;
  font-size: 12px;
  letter-spacing: 4px;
  color: #b6c0cf;
}
// è¾“入框统一样式
:deep(.el-input__wrapper) {
  border-radius: 10px;
  padding: 5px 14px;
  box-shadow: 0 0 0 1px var(--app-border, #e8ecf1) inset;
  transition: box-shadow var(--app-transition, 0.2s);
  &:hover {
    box-shadow: 0 0 0 1px var(--app-text-tertiary, #86909c) inset;
  }
  &.is-focus {
    box-shadow: 0 0 0 1.5px var(--app-primary, #2c51d9) inset;
  }
}
@media (max-width: 480px) {
  .login-panel {
    padding: 20px 16px;
    background: #fff;
  }
  .login-card {
    padding: 28px 24px 20px;
    box-shadow: none;
  }
}
</style>
src/views/login/components/LoginBrand.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,230 @@
<template>
  <aside class="brand">
    <!-- å“ç‰Œåœºæ™¯èƒŒæ™¯ -->
    <div class="brand__bg"></div>
    <!-- å·¦ä¸Šä¼ä¸š Logo -->
    <div class="brand__logo">
      <img :src="logo" alt="健齿齿科器材" />
    </div>
    <div class="brand__hero">
      <h1 class="brand__headline">专业专注 <em>健康齿科</em></h1>
      <p class="brand__desc">为全球口腔医疗机构提供高品质的齿科器材与解决方案</p>
      <ul class="brand__features">
        <li v-for="f in features" :key="f.label">
          <el-icon :size="26"><component :is="f.icon" /></el-icon>
          <div class="brand__feature-text">
            <span class="brand__feature-label">{{ f.label }}</span>
            <span class="brand__feature-sub">{{ f.sub }}</span>
          </div>
        </li>
      </ul>
    </div>
    <!-- å…¨æ¯ç‰™é½¿ 3D å±•示 -->
    <HolographicTooth class="brand__tooth" />
    <div class="brand__tags">
      <span class="brand__tags-slash">///</span>
      <div class="brand__tags-list">
        <span>数字化</span>
        <span>智能化</span>
        <span>国际化</span>
      </div>
    </div>
  </aside>
</template>
<script setup>
import HolographicTooth from '@/components/HolographicTooth.vue'
import logo from '@/assets/login/JCCKLogo.png'
const features = [
  { icon: 'CircleCheck', label: '品质保障', sub: '严控质量标准' },
  { icon: 'Setting', label: '智能制造', sub: '数字化生产管理' },
  { icon: 'Connection', label: '服务全球', sub: '助力口腔健康事业' }
]
</script>
<style lang="scss" scoped>
.brand {
  position: relative;
  flex: 0 0 58%;
  display: flex;
  flex-direction: column;
  padding: 40px 56px;
  box-sizing: border-box;
  overflow: hidden;
}
.brand__bg {
  position: absolute;
  inset: 0;
  // ä¸‰å±‚叠加:牙齿底衬暗部(让全息发光可见) + å·¦ä¾§æ–‡å­—蒙层 + å“ç‰ŒèƒŒæ™¯å›¾
  background:
    radial-gradient(ellipse 50% 48% at 17% 76%, rgba(9, 24, 58, 0.52) 0%, rgba(9, 24, 58, 0.26) 40%, rgba(9, 24, 58, 0) 68%),
    linear-gradient(100deg, rgba(247, 250, 254, 0.72) 0%, rgba(247, 250, 254, 0.28) 42%, rgba(247, 250, 254, 0) 68%),
    url('../../../assets/login/login-background.png') center / cover no-repeat;
}
.brand__logo {
  position: relative;
  z-index: 2;
  img {
    height: 46px;
    padding: 6px 12px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 4px 16px rgba(16, 42, 96, 0.08);
    object-fit: contain;
  }
}
.brand__hero {
  position: relative;
  z-index: 2;
  margin-top: 96px;
  max-width: 560px;
}
.brand__headline {
  margin: 0;
  font-size: 46px;
  font-weight: 700;
  letter-spacing: 4px;
  color: #17233d;
  em {
    font-style: normal;
    color: var(--app-primary, #2c51d9);
  }
}
.brand__desc {
  margin: 18px 0 0;
  font-size: 16px;
  letter-spacing: 1px;
  color: #3d4a63;
}
.brand__features {
  display: flex;
  gap: 40px;
  margin: 44px 0 0;
  padding: 0;
  list-style: none;
  li {
    display: flex;
    align-items: center;
    gap: 12px;
    color: #2c51d9;
  }
}
.brand__feature-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.brand__feature-label {
  font-size: 14px;
  font-weight: 600;
  color: #1d2129;
}
.brand__feature-sub {
  font-size: 12px;
  color: #6b7a90;
}
.brand__tooth {
  position: absolute;
  z-index: 2;
  left: 48px;
  bottom: 96px;
  width: 320px;
  height: 380px;
}
.brand__tags {
  position: absolute;
  z-index: 2;
  left: 56px;
  bottom: 40px;
  display: flex;
  align-items: flex-end;
  gap: 12px;
}
.brand__tags-slash {
  font-size: 18px;
  font-weight: 700;
  letter-spacing: 2px;
  color: var(--app-primary, #2c51d9);
}
.brand__tags-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 14px;
  letter-spacing: 4px;
  color: #3d4a63;
}
@media (max-width: 1440px) {
  .brand {
    padding: 32px 40px;
  }
  .brand__headline {
    font-size: 38px;
  }
  .brand__hero {
    margin-top: 72px;
  }
  .brand__tooth {
    width: 260px;
    height: 320px;
    left: 36px;
    bottom: 88px;
  }
  .brand__tags {
    left: 40px;
  }
}
@media (max-width: 1200px) {
  .brand {
    flex-basis: 50%;
  }
  .brand__headline {
    font-size: 32px;
  }
  .brand__features {
    gap: 24px;
  }
  .brand__tooth {
    width: 200px;
    height: 260px;
    bottom: 96px;
  }
}
@media (max-width: 992px) {
  .brand {
    display: none;
  }
}
</style>
vite.config.js
@@ -8,7 +8,7 @@
  const { VITE_APP_ENV } = env;
  const baseUrl =
      env.VITE_APP_ENV === "development"
          ? "http://1.15.17.182:9003"
          ? "http://localhost:7003"
          : env.VITE_BASE_API;
  const javaUrl =
      env.VITE_APP_ENV === "development"