feat(home): 重构首页工作台界面并新增全息牙齿组件
- 使用新组件化架构重构首页,提升用户体验和代码可维护性
- 新增 HolographicTooth 全息牙齿3D可视化组件,基于Three.js实现
- 集成多种数据展示图表组件,包括工序统计、合同分析、财务状况等
- 优化页面布局结构,采用响应式设计提升移动端适配效果
- 移除旧版样式定义,引入设计系统tokens统一视觉规范
- 优化资源管理,改进组件生命周期和内存释放机制
| | |
| | | "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", |
| | |
| | | @import './tokens.scss';
|
| | | @import './variables.module.scss';
|
| | | @import './mixin.scss';
|
| | | @import './transition.scss';
|
| | |
| | | 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);
|
| | | }
|
| | | }
|
| | | }
|
| | |
|
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | <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> |
| | | <div class="workbench"> |
| | | <HomeWelcome |
| | | :user-name="displayName" |
| | | :role-name="userStore.roleName" |
| | | :dept-name="userStore.deptName" |
| | | :todo-count="todos.length" |
| | | :today-inbound="business.todayInventoryNum || 0" |
| | | /> |
| | | |
| | | <!-- ç¨æ·ä¿¡æ¯å¡ç --> |
| | | <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> |
| | | <HomeStats :business="business" /> |
| | | |
| | | </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 class="workbench__row workbench__row--main"> |
| | | <HomeProcessChart class="workbench__grow" /> |
| | | <HomeTodo class="workbench__side" :list="todos" :loading="todosLoading" /> |
| | | </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 class="workbench__row workbench__row--triple"> |
| | | <HomeContractChart /> |
| | | <HomeFinanceChart /> |
| | | <HomeQuickEntry /> |
| | | </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; |
| | | } |
| | | </style> |
| | | |
| | | .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> |
| | |
| | | <template>
|
| | | <div class="login">
|
| | | <el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form">
|
| | | <h3 class="title">{{ title }}</h3>
|
| | | <el-divider />
|
| | | <el-form-item prop="username">
|
| | | <el-input
|
| | | v-model="loginForm.username"
|
| | | type="text"
|
| | | size="large"
|
| | | auto-complete="off"
|
| | | placeholder="è´¦å·"
|
| | | >
|
| | | <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"
|
| | | auto-complete="off"
|
| | | placeholder="å¯ç "
|
| | | show-password
|
| | | @keyup.enter="handleLogin"
|
| | | >
|
| | | <template #prefix><svg-icon icon-class="password" class="el-input__icon input-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%;">
|
| | | <el-button
|
| | | :loading="loading"
|
| | | size="large"
|
| | | type="primary"
|
| | | style="width:100%;"
|
| | | @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>
|
| | | </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'
|
| | |
|
| | | const title = import.meta.env.VITE_APP_TITLE
|
| | | const userStore = useUserStore()
|
| | | const route = useRoute()
|
| | | const router = useRouter()
|
| | | const { proxy } = getCurrentInstance()
|
| | |
|
| | | const loginForm = ref({
|
| | | username: "",
|
| | | password: "",
|
| | | rememberMe: false,
|
| | | })
|
| | |
|
| | | 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) => {
|
| | | redirect.value = newRoute.query && newRoute.query.redirect
|
| | | }, { immediate: true })
|
| | |
|
| | | function handleLogin() {
|
| | | proxy.$refs.loginRef.validate(valid => {
|
| | | if (valid) {
|
| | | loading.value = true
|
| | | // å¾éäºéè¦è®°ä½å¯ç è®¾ç½®å¨ cookie ä¸è®¾ç½®è®°ä½ç¨æ·ååå¯ç
|
| | | Cookies.set("username", loginForm.value.username, { expires: 30 })
|
| | | Cookies.set("password", encrypt(loginForm.value.password), { expires: 30 })
|
| | | Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 })
|
| | | userStore.loginCheckFactory(loginForm.value).then(res => {
|
| | | const query = route.query
|
| | | const otherQueryParams = Object.keys(query).reduce((acc, cur) => {
|
| | | if (cur !== "redirect") {
|
| | | acc[cur] = query[cur]
|
| | | }
|
| | | return acc
|
| | | }, {})
|
| | | 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 getCookie() {
|
| | | const username = Cookies.get("username")
|
| | | const password = Cookies.get("password")
|
| | | const rememberMe = Cookies.get("rememberMe")
|
| | | 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 {
|
| | | 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;
|
| | | }
|
| | |
|
| | | .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;
|
| | | }
|
| | | }
|
| | | .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;
|
| | | width: 100%;
|
| | | text-align: center;
|
| | | color: #fff;
|
| | | font-family: Arial;
|
| | | font-size: 12px;
|
| | | letter-spacing: 1px;
|
| | | }
|
| | | .login-code-img {
|
| | | height: 40px;
|
| | | padding-left: 12px;
|
| | | }
|
| | | :deep() {
|
| | | .el-form-item--default {
|
| | | margin-bottom: 36px;
|
| | | }
|
| | | }
|
| | | </style>
|
| | | <template> |
| | | <div class="login"> |
| | | <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="请è¾å
¥ç¨æ·å" |
| | | @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="请è¾å
¥å¯ç " |
| | | show-password |
| | | @keyup.enter="handleLogin" |
| | | > |
| | | <template #prefix><el-icon><Lock /></el-icon></template> |
| | | </el-input> |
| | | </el-form-item> |
| | | |
| | | <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" |
| | | class="login-btn" |
| | | @click.prevent="handleLogin" |
| | | > |
| | | <span v-if="!loading">ç» å½</span> |
| | | <span v-else>ç» å½ ä¸...</span> |
| | | </el-button> |
| | | </el-form> |
| | | |
| | | <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 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 userStore = useUserStore() |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | const loginForm = ref({ |
| | | username: "", |
| | | password: "", |
| | | rememberMe: false, |
| | | }) |
| | | |
| | | const loginRules = { |
| | | username: [{ required: true, trigger: "blur", message: "请è¾å
¥æ¨çè´¦å·" }], |
| | | password: [{ required: true, trigger: "blur", message: "请è¾å
¥æ¨çå¯ç " }], |
| | | } |
| | | |
| | | const loading = ref(false) |
| | | const redirect = ref(undefined) |
| | | |
| | | watch(route, (newRoute) => { |
| | | redirect.value = newRoute.query && newRoute.query.redirect |
| | | }, { immediate: true }) |
| | | |
| | | function handleLogin() { |
| | | proxy.$refs.loginRef.validate(valid => { |
| | | if (valid) { |
| | | loading.value = true |
| | | // å¾éäºéè¦è®°ä½å¯ç è®¾ç½®å¨ cookie ä¸è®¾ç½®è®°ä½ç¨æ·ååå¯ç |
| | | Cookies.set("username", loginForm.value.username, { expires: 30 }) |
| | | Cookies.set("password", encrypt(loginForm.value.password), { expires: 30 }) |
| | | Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 }) |
| | | userStore.loginCheckFactory(loginForm.value).then(res => { |
| | | const query = route.query |
| | | const otherQueryParams = Object.keys(query).reduce((acc, cur) => { |
| | | if (cur !== "redirect") { |
| | | acc[cur] = query[cur] |
| | | } |
| | | return acc |
| | | }, {}) |
| | | router.push({ path: redirect.value || "/", query: otherQueryParams }) |
| | | }).catch(() => { |
| | | loading.value = false |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | function handleForgot() { |
| | | proxy.$modal.msg("请è系系ç»ç®¡çåéç½®å¯ç ") |
| | | } |
| | | |
| | | function handleWecom() { |
| | | proxy.$modal.msg("ä¼ä¸å¾®ä¿¡ç»å½ææªå¼æ¾") |
| | | } |
| | | |
| | | function getCookie() { |
| | | const username = Cookies.get("username") |
| | | 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) |
| | | } |
| | | } |
| | | |
| | | getCookie() |
| | | </script> |
| | | |
| | | <style lang='scss' scoped> |
| | | .login { |
| | | display: flex; |
| | | height: 100%; |
| | | min-height: 100vh; |
| | | background: var(--app-bg, #f4f6f9); |
| | | } |
| | | |
| | | .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%); |
| | | } |
| | | |
| | | .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; |
| | | margin-bottom: 30px; |
| | | } |
| | | |
| | | .login-card__logo { |
| | | height: 40px; |
| | | object-fit: contain; |
| | | } |
| | | |
| | | .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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | 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" |