From 07cfe16049ada779c8c820b809c6531e69e84bc1 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期二, 15 九月 2026 13:03:03 +0800
Subject: [PATCH] feat(home): 重构首页工作台界面并新增全息牙齿组件
---
src/components/HolographicTooth.vue | 352 +++++
src/views/home/components/HomeWelcome.vue | 133 ++
src/views/index.vue | 1312 +-------------------
src/views/home/components/HomeQuickEntry.vue | 120 +
src/assets/styles/index.scss | 24
src/assets/login/login-background.png | 0
src/views/home/components/charts/HomeContractChart.vue | 186 ++
multiple/assets/screen/login-background.png | 0
src/views/home/components/HomeStats.vue | 113 +
multiple/assets/logo/JCCKLogo.png | 0
src/assets/login/JCCKLogo.png | 0
src/views/login/components/LoginBrand.vue | 230 +++
src/views/home/components/charts/HomeQualityChart.vue | 122 +
docs/img.png | 0
src/views/login.vue | 564 +++++---
src/views/home/components/charts/HomeFinanceChart.vue | 46
package.json | 1
src/views/home/components/charts/HomeInvoiceChart.vue | 87 +
src/views/home/components/charts/HomeProcessChart.vue | 289 ++++
src/views/home/components/HomeTodo.vue | 101 +
vite.config.js | 2
src/views/home/components/HomePanel.vue | 87 +
22 files changed, 2,282 insertions(+), 1,487 deletions(-)
diff --git a/docs/img.png b/docs/img.png
new file mode 100644
index 0000000..f817fd0
--- /dev/null
+++ b/docs/img.png
Binary files differ
diff --git a/multiple/assets/logo/JCCKLogo.png b/multiple/assets/logo/JCCKLogo.png
index 3ec29e2..41f331a 100644
--- a/multiple/assets/logo/JCCKLogo.png
+++ b/multiple/assets/logo/JCCKLogo.png
Binary files differ
diff --git a/multiple/assets/screen/login-background.png b/multiple/assets/screen/login-background.png
index ace9d53..a7a993d 100644
--- a/multiple/assets/screen/login-background.png
+++ b/multiple/assets/screen/login-background.png
Binary files differ
diff --git a/package.json b/package.json
index e80e1e7..71facf7 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"qrcode": "^1.5.4",
"sortablejs": "^1.15.6",
"splitpanes": "3.1.5",
+ "three": "^0.186.0",
"vue": "3.4.31",
"vue-cropper": "1.1.1",
"vue-easy-lightbox": "^1.19.0",
diff --git a/src/assets/login/JCCKLogo.png b/src/assets/login/JCCKLogo.png
new file mode 100644
index 0000000..41f331a
--- /dev/null
+++ b/src/assets/login/JCCKLogo.png
Binary files differ
diff --git a/src/assets/login/login-background.png b/src/assets/login/login-background.png
new file mode 100644
index 0000000..a7a993d
--- /dev/null
+++ b/src/assets/login/login-background.png
Binary files differ
diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss
index 13d26ee..5b7949a 100644
--- a/src/assets/styles/index.scss
+++ b/src/assets/styles/index.scss
@@ -1,3 +1,4 @@
+@import './tokens.scss';
@import './variables.module.scss';
@import './mixin.scss';
@import './transition.scss';
@@ -95,29 +96,6 @@
content: " ";
clear: both;
height: 0;
- }
-}
-
-aside {
- background: #eef1f6;
- padding: 8px 24px;
- margin-bottom: 20px;
- border-radius: 2px;
- display: block;
- line-height: 32px;
- font-size: 16px;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
- color: #2c3e50;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-
- a {
- color: #337ab7;
- cursor: pointer;
-
- &:hover {
- color: rgb(32, 160, 255);
- }
}
}
diff --git a/src/components/HolographicTooth.vue b/src/components/HolographicTooth.vue
new file mode 100644
index 0000000..2fb9e98
--- /dev/null
+++ b/src/components/HolographicTooth.vue
@@ -0,0 +1,352 @@
+<template>
+ <div ref="host" class="holo-tooth" aria-label="鍏ㄦ伅鐗欓娇">
+ <div class="holo-grid"></div>
+ <div class="holo-label">DIGITAL DENTAL 路 3D ANALYSIS</div>
+ </div>
+</template>
+
+<script setup>
+import { onMounted, onBeforeUnmount, ref } from 'vue'
+import * as THREE from 'three'
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
+
+const host = ref(null)
+
+let renderer, scene, camera, controls, animationId
+let tooth, toothPoints, scanRing, scanLine, particleField
+let resizeObserver
+
+// 鐢熸垚涓�涓棤闇�涓嬭浇妯″瀷鐨勨�滅墮榻库�濆弬鏁版洸闈€��
+// 鍚庣画濡傛灉鏈夌湡瀹� tooth.glb锛屽彲鐩存帴鏇挎崲 buildProceduralTooth()銆�
+function buildProceduralTooth() {
+ const positions = []
+ const normals = []
+ const uvs = []
+ const indices = []
+
+ // v: 0 鐗欐牴搴� -> 1 鐗欏啝椤讹紱a=0 涓哄乏鍙冲渚э紙鐗欏皷鏂瑰悜锛夛紝a=卤蟺/2 涓烘涓紙鍑归櫡鏂瑰悜锛�
+ const rows = 60
+ const cols = 84
+ const FLAT = 0.62 // 鍓嶅悗鍘嬫墎锛屽憟鐜板崱閫氱墮姣斾緥
+
+ for (let iy = 0; iy <= rows; iy++) {
+ const v = iy / rows
+
+ let radius
+ let y
+ let dip = 0
+ let notch = 0
+
+ let rootTaper = 0
+ let tipDrop = 0
+ let tipPinch = 1
+ if (v < 0.42) {
+ // 鐗欐牴锛氱矖鐭弻鏍癸紝鏍瑰皷鏀剁粏鍦嗛挐锛屼腑澶繁 U 褰㈠垎鍙�
+ const t = v / 0.42
+ if (t < 0.45) tipPinch = 0.78 + 0.22 * Math.pow(t / 0.45, 1.3)
+ radius = 0.42 + 0.16 * Math.pow(t, 0.75)
+ y = -1.28 + t * 1.18
+ notch = 0.66 * Math.pow(1 - t, 1.4)
+ rootTaper = 1 - t
+ tipDrop = 0.12 * rootTaper
+ } else {
+ // 鐗欏啝锛氬渾椤堕紦鍐犮�佸挰鍚堥潰骞崇紦涓ぎ娌�
+ const t = (v - 0.42) / 0.58
+ radius = 0.58 + 0.4 * Math.pow(Math.sin(t * Math.PI * 0.72), 1.25)
+ y = -0.1 + t * 1.48
+ if (t > 0.55) dip = 0.26 * Math.pow((t - 0.55) / 0.45, 1.15)
+ if (t > 0.78) radius *= Math.sqrt(Math.max(0, 1 - Math.pow(Math.min(1, (t - 0.78) / 0.22), 2)))
+ }
+
+ for (let ix = 0; ix <= cols; ix++) {
+ const u = ix / cols
+ const a = u * Math.PI * 2
+
+ const cosA = Math.cos(a)
+ const absC = Math.abs(cosA)
+ const center = Math.pow(1 - absC, 0.9) // 姝d腑=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锛歜loom 鍚庢湡閫氶亾浼氫涪澶辩敾甯� 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>
diff --git a/src/views/home/components/HomePanel.vue b/src/views/home/components/HomePanel.vue
new file mode 100644
index 0000000..333e4f8
--- /dev/null
+++ b/src/views/home/components/HomePanel.vue
@@ -0,0 +1,87 @@
+<template>
+ <section class="app-panel home-panel">
+ <header v-if="title || $slots.extra" class="home-panel__head">
+ <div class="home-panel__title-wrap">
+ <h3 class="home-panel__title">{{ title }}</h3>
+ <span v-if="subtitle" class="home-panel__subtitle">{{ subtitle }}</span>
+ </div>
+ <div class="home-panel__extra">
+ <slot name="extra"></slot>
+ </div>
+ </header>
+ <div v-loading="loading" class="home-panel__body" :style="bodyStyle">
+ <slot></slot>
+ </div>
+ </section>
+</template>
+
+<script setup>
+defineProps({
+ title: { type: String, default: '' },
+ subtitle: { type: String, default: '' },
+ loading: { type: Boolean, default: false },
+ bodyStyle: { type: [String, Object], default: '' }
+})
+</script>
+
+<style scoped>
+.home-panel {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.home-panel__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 14px 16px 0;
+}
+
+.home-panel__title-wrap {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+
+.home-panel__title {
+ position: relative;
+ margin: 0;
+ padding-left: 10px;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--app-text);
+}
+
+.home-panel__title::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 14px;
+ border-radius: 2px;
+ background: var(--app-primary);
+}
+
+.home-panel__subtitle {
+ font-size: 12px;
+ color: var(--app-text-tertiary);
+}
+
+.home-panel__extra {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.home-panel__body {
+ flex: 1;
+ min-height: 0;
+ padding: 14px 16px 16px;
+}
+</style>
diff --git a/src/views/home/components/HomeQuickEntry.vue b/src/views/home/components/HomeQuickEntry.vue
new file mode 100644
index 0000000..196f0c1
--- /dev/null
+++ b/src/views/home/components/HomeQuickEntry.vue
@@ -0,0 +1,120 @@
+<template>
+ <HomePanel title="蹇嵎鍏ュ彛" subtitle="鎸夋潈闄愯嚜鍔ㄥ睍绀哄父鐢ㄥ姛鑳�">
+ <div class="entry">
+ <div
+ v-for="item in entries"
+ :key="item.title"
+ class="entry__item"
+ role="link"
+ tabindex="0"
+ @click="go(item.path)"
+ @keyup.enter="go(item.path)"
+ >
+ <div class="entry__icon">
+ <el-icon :size="18"><component :is="item.icon" /></el-icon>
+ </div>
+ <span class="entry__label">{{ item.title }}</span>
+ </div>
+ <el-empty v-if="!entries.length" description="鏆傛棤鍙敤鍏ュ彛" :image-size="48" />
+ </div>
+ </HomePanel>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import HomePanel from './HomePanel.vue'
+
+const router = useRouter()
+
+// 鏈熸湜灞曠ず鐨勫父鐢ㄥ叆鍙o紙鎸変笟鍔¢『搴忥級锛涚敤鎴锋棤瀵瑰簲鑿滃崟鏉冮檺鏃惰嚜鍔ㄨ烦杩�
+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: '瀹㈡埛妗f', 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>
diff --git a/src/views/home/components/HomeStats.vue b/src/views/home/components/HomeStats.vue
new file mode 100644
index 0000000..4029e29
--- /dev/null
+++ b/src/views/home/components/HomeStats.vue
@@ -0,0 +1,113 @@
+<template>
+ <div class="stats">
+ <div v-for="(item, i) in cards" :key="item.key" class="app-panel stat-card">
+ <div class="stat-card__icon" :class="`is-${item.tone}`">
+ <el-icon :size="18"><component :is="item.icon" /></el-icon>
+ </div>
+ <div class="stat-card__main">
+ <div class="stat-card__label">{{ item.label }}</div>
+ <div class="stat-card__value app-num">{{ formatted[i] }}<span class="stat-card__unit">{{ item.unit }}</span></div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import {
+ Money, DocumentCopy, ShoppingCart, CreditCard, Box, Van
+} from '@element-plus/icons-vue'
+import { useCountUp, formatNum } from '../useCountUp'
+
+const props = defineProps({
+ business: { type: Object, default: () => ({}) }
+})
+
+const cards = [
+ { key: 'monthSaleMoney', label: '鏈湀閿�鍞', unit: '鍏�', icon: Money, tone: 'primary' },
+ { key: 'monthSaleHaveMoney', label: '鏈紑绁ㄩ噾棰�', unit: '鍏�', icon: DocumentCopy, tone: 'warning' },
+ { key: 'monthPurchaseMoney', label: '鏈湀閲囪喘棰�', unit: '鍏�', icon: ShoppingCart, tone: 'info' },
+ { key: 'monthPurchaseHaveMoney', label: '寰呬粯娆鹃噾棰�', unit: '鍏�', icon: CreditCard, tone: 'danger' },
+ { key: 'inventoryNum', label: '褰撳墠搴撳瓨鎬婚噺', unit: '浠�', icon: Box, tone: 'success' },
+ { key: 'todayInventoryNum', label: '浠婃棩鍏ュ簱', unit: '浠�', icon: Van, tone: 'primary' }
+]
+
+const sources = cards.map((c) => computed(() => Number(props.business[c.key]) || 0))
+const values = sources.map((s) => useCountUp(s))
+const formatted = computed(() => values.map((v) => formatNum(v.value)))
+</script>
+
+<style scoped>
+.stats {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: var(--app-gap);
+}
+
+.stat-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ min-width: 0;
+ transition: box-shadow var(--app-transition), transform var(--app-transition);
+}
+
+.stat-card:hover {
+ box-shadow: var(--app-shadow-md);
+ transform: translateY(-1px);
+}
+
+.stat-card__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ flex-shrink: 0;
+ border-radius: var(--app-radius-sm);
+}
+
+.stat-card__icon.is-primary { background: var(--app-primary-soft); color: var(--app-primary); }
+.stat-card__icon.is-success { background: var(--app-success-soft); color: var(--app-success); }
+.stat-card__icon.is-warning { background: var(--app-warning-soft); color: var(--app-warning); }
+.stat-card__icon.is-danger { background: var(--app-danger-soft); color: var(--app-danger); }
+.stat-card__icon.is-info { background: var(--app-info-soft); color: var(--app-info); }
+
+.stat-card__main {
+ min-width: 0;
+}
+
+.stat-card__label {
+ font-size: 12px;
+ color: var(--app-text-tertiary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.stat-card__value {
+ margin-top: 4px;
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--app-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.stat-card__unit {
+ margin-left: 4px;
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--app-text-tertiary);
+}
+
+@media (max-width: 1440px) {
+ .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@media (max-width: 768px) {
+ .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+</style>
diff --git a/src/views/home/components/HomeTodo.vue b/src/views/home/components/HomeTodo.vue
new file mode 100644
index 0000000..1142c6c
--- /dev/null
+++ b/src/views/home/components/HomeTodo.vue
@@ -0,0 +1,101 @@
+<template>
+ <HomePanel title="寰呭姙涓績" :loading="loading">
+ <template #extra>
+ <span v-if="list.length" class="todo-badge app-num">{{ list.length }} 椤瑰緟澶勭悊</span>
+ </template>
+ <ul v-if="list.length" class="todo">
+ <li v-for="item in list" :key="item.id" class="todo__item">
+ <div class="todo__row">
+ <span class="todo__dot"></span>
+ <span class="todo__reason">{{ item.approveReason || '寰呭姙浜嬮」' }}</span>
+ <span class="todo__time">{{ item.approveTime }}</span>
+ </div>
+ <div class="todo__meta">
+ <span>缂栧彿 {{ item.approveId }}</span>
+ <span>閮ㄩ棬 {{ item.approveDeptName }}</span>
+ </div>
+ </li>
+ </ul>
+ <el-empty v-else description="鏆傛棤寰呭姙浜嬮」" :image-size="56" />
+ </HomePanel>
+</template>
+
+<script setup>
+import HomePanel from './HomePanel.vue'
+
+defineProps({
+ list: { type: Array, default: () => [] },
+ loading: { type: Boolean, default: false }
+})
+</script>
+
+<style scoped>
+.todo-badge {
+ font-size: 12px;
+ color: var(--app-warning);
+ background: var(--app-warning-soft);
+ border-radius: 10px;
+ padding: 2px 10px;
+}
+
+.todo {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+.todo__item {
+ padding: 10px 12px;
+ border-radius: var(--app-radius-sm);
+ transition: background var(--app-transition);
+}
+
+.todo__item:hover {
+ background: var(--app-surface-hover);
+}
+
+.todo__item + .todo__item {
+ margin-top: 4px;
+}
+
+.todo__row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.todo__dot {
+ width: 6px;
+ height: 6px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background: var(--app-primary);
+}
+
+.todo__reason {
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+ color: var(--app-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.todo__time {
+ flex-shrink: 0;
+ font-size: 12px;
+ color: var(--app-text-tertiary);
+}
+
+.todo__meta {
+ display: flex;
+ gap: 16px;
+ margin: 6px 0 0 14px;
+ font-size: 12px;
+ color: var(--app-text-tertiary);
+}
+</style>
diff --git a/src/views/home/components/HomeWelcome.vue b/src/views/home/components/HomeWelcome.vue
new file mode 100644
index 0000000..3db483d
--- /dev/null
+++ b/src/views/home/components/HomeWelcome.vue
@@ -0,0 +1,133 @@
+<template>
+ <section class="app-panel welcome">
+ <div class="welcome__left">
+ <h2 class="welcome__title">{{ greeting }}锛寋{ userName }}</h2>
+ <p class="welcome__meta">
+ <span>{{ dateText }}</span>
+ <span class="welcome__sep">|</span>
+ <span>{{ roleName || '绯荤粺鐢ㄦ埛' }}</span>
+ <template v-if="deptName">
+ <span class="welcome__sep">|</span>
+ <span>{{ deptName }}</span>
+ </template>
+ </p>
+ </div>
+ <div class="welcome__chips">
+ <div class="welcome__chip">
+ <span class="welcome__chip-label">寰呭姙浠诲姟</span>
+ <span class="welcome__chip-value app-num">{{ todoCount }}</span>
+ </div>
+ <div class="welcome__chip">
+ <span class="welcome__chip-label">寰呯敓浜ц鍗�</span>
+ <span class="welcome__chip-value app-num">{{ pendingOrders }}</span>
+ </div>
+ <div class="welcome__chip">
+ <span class="welcome__chip-label">浠婃棩鍏ュ簱</span>
+ <span class="welcome__chip-value app-num">{{ todayInbound }}</span>
+ </div>
+ <div class="welcome__chip">
+ <span class="welcome__chip-label">浠婃棩瀹屾垚妫�楠�</span>
+ <span class="welcome__chip-value app-num">{{ todayChecked }}</span>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import dayjs from 'dayjs'
+import { qualityInspectionCount, orderCount } from '@/api/viewIndex'
+
+const props = defineProps({
+ userName: { type: String, default: '' },
+ roleName: { type: String, default: '' },
+ deptName: { type: String, default: '' },
+ todoCount: { type: Number, default: 0 },
+ todayInbound: { type: [Number, String], default: 0 }
+})
+
+const todayChecked = ref(0)
+const pendingOrders = ref(0)
+
+const greeting = computed(() => {
+ const h = dayjs().hour()
+ if (h < 6) return '澶滄繁浜�'
+ if (h < 12) return '鏃╀笂濂�'
+ if (h < 14) return '涓崍濂�'
+ if (h < 18) return '涓嬪崍濂�'
+ return '鏅氫笂濂�'
+})
+
+const weekMap = ['鍛ㄦ棩', '鍛ㄤ竴', '鍛ㄤ簩', '鍛ㄤ笁', '鍛ㄥ洓', '鍛ㄤ簲', '鍛ㄥ叚']
+const dateText = computed(() => {
+ const d = dayjs()
+ return `${d.format('YYYY骞碝M鏈圖D鏃�')} ${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>
diff --git a/src/views/home/components/charts/HomeContractChart.vue b/src/views/home/components/charts/HomeContractChart.vue
new file mode 100644
index 0000000..075d240
--- /dev/null
+++ b/src/views/home/components/charts/HomeContractChart.vue
@@ -0,0 +1,186 @@
+<template>
+ <HomePanel title="瀹㈡埛鍚堝悓閲戦鍒嗘瀽" :loading="loading">
+ <div class="contract">
+ <div class="contract__head">
+ <span class="contract__label">鎬诲悎鍚岄噾棰濓紙鍏冿級</span>
+ <div class="contract__metrics">
+ <span class="contract__sum app-num">{{ formatNum(sum) }}</span>
+ <span class="contract__rate">鍛ㄥ悓姣� <b :class="yny >= 0 ? 'is-up' : 'is-down'">{{ yny }}%</b></span>
+ <span class="contract__rate">鐜瘮 <b :class="chain >= 0 ? 'is-up' : 'is-down'">{{ chain }}%</b></span>
+ </div>
+ </div>
+ <div class="contract__body">
+ <div class="contract__pie">
+ <Echarts :legend="{ show: false }" :chartStyle="{ width: '100%', height: '100%' }"
+ :series="pieSeries" :tooltip="pieTooltip" />
+ </div>
+ <ul class="contract__list">
+ <li v-for="item in pieList" :key="item.name" class="contract__row">
+ <span class="contract__dot" :style="{ background: item.itemStyle.color }"></span>
+ <span class="contract__name" :title="item.name">{{ item.name }}</span>
+ <span class="contract__rate-value app-num">{{ item.rate }}%</span>
+ <span class="contract__value app-num">锟{ formatNum(item.value) }}</span>
+ </li>
+ <el-empty v-if="!pieList.length" description="鏆傛棤鏁版嵁" :image-size="48" />
+ </ul>
+ </div>
+ </div>
+ </HomePanel>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import Echarts from '@/components/Echarts/echarts.vue'
+import HomePanel from '../HomePanel.vue'
+import { analysisCustomerContractAmounts } from '@/api/viewIndex'
+import { formatNum } from '../../useCountUp'
+
+const PIE_COLORS = ['#2C51D9', '#0F9960', '#D97706', '#D54941', '#7A5AF8', '#19C6C6', '#FF8A3D', '#5B8CFF', '#B37FEB', '#1377EF']
+
+const loading = ref(true)
+const sum = ref(0)
+const yny = ref(0)
+const chain = ref(0)
+const pieList = ref([])
+
+const pieSeries = ref([
+ {
+ type: 'pie',
+ radius: ['62%', '86%'],
+ center: ['50%', '50%'],
+ avoidLabelOverlap: false,
+ itemStyle: { borderColor: '#fff', borderWidth: 2, borderRadius: 4 },
+ label: { show: false },
+ data: []
+ }
+])
+
+const pieTooltip = {
+ trigger: 'item',
+ formatter: (params) => `${params.name} ${formatNum(params.value)}鍏� ${params.percent}%`
+}
+
+onMounted(() => {
+ analysisCustomerContractAmounts().then((res) => {
+ const d = res.data || {}
+ sum.value = d.sum
+ yny.value = d.yny
+ chain.value = d.chain
+ pieList.value = (d.item || []).map((item, index) => ({
+ ...item,
+ itemStyle: { color: PIE_COLORS[index % PIE_COLORS.length] }
+ }))
+ pieSeries.value[0].data = pieList.value
+ }).finally(() => { loading.value = false })
+})
+</script>
+
+<style scoped>
+.contract {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ height: 100%;
+}
+
+.contract__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 14px;
+ border-radius: var(--app-radius-sm);
+ background: var(--app-surface-hover);
+}
+
+.contract__label {
+ font-size: 13px;
+ color: var(--app-text-secondary);
+}
+
+.contract__metrics {
+ display: flex;
+ align-items: baseline;
+ gap: 16px;
+}
+
+.contract__sum {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--app-text);
+}
+
+.contract__rate {
+ font-size: 12px;
+ color: var(--app-text-tertiary);
+}
+
+.contract__rate .is-up { color: var(--app-danger); }
+.contract__rate .is-down { color: var(--app-success); }
+
+.contract__body {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex: 1;
+ min-height: 0;
+}
+
+.contract__pie {
+ width: 160px;
+ height: 160px;
+ flex-shrink: 0;
+}
+
+.contract__list {
+ flex: 1;
+ min-width: 0;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 160px;
+ overflow-y: auto;
+}
+
+.contract__row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 0;
+ font-size: 13px;
+ border-bottom: 1px dashed var(--app-divider);
+}
+
+.contract__row:last-child {
+ border-bottom: none;
+}
+
+.contract__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.contract__name {
+ flex: 1;
+ min-width: 0;
+ color: var(--app-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.contract__rate-value {
+ width: 56px;
+ text-align: right;
+ color: var(--app-text-tertiary);
+}
+
+.contract__value {
+ width: 120px;
+ text-align: right;
+ color: var(--app-text);
+}
+</style>
diff --git a/src/views/home/components/charts/HomeFinanceChart.vue b/src/views/home/components/charts/HomeFinanceChart.vue
new file mode 100644
index 0000000..7acf6d6
--- /dev/null
+++ b/src/views/home/components/charts/HomeFinanceChart.vue
@@ -0,0 +1,46 @@
+<template>
+ <HomePanel title="搴旀敹搴斾粯缁熻" :loading="loading">
+ <Echarts
+ :chartStyle="{ width: '100%', height: '260px' }"
+ :grid="grid"
+ :series="series"
+ :tooltip="tooltip"
+ :xAxis="xAxis"
+ :yAxis="yAxis"
+ style="height: 260px"
+ />
+ </HomePanel>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import Echarts from '@/components/Echarts/echarts.vue'
+import HomePanel from '../HomePanel.vue'
+import { statisticsReceivablePayable } from '@/api/viewIndex'
+
+const loading = ref(true)
+
+const series = ref([
+ {
+ type: 'bar',
+ barWidth: 14,
+ data: [],
+ label: { show: true, position: 'right', color: 'rgba(0,0,0,0.55)' }
+ }
+])
+
+const grid = { left: '3%', right: '10%', bottom: '3%', top: 10, containLabel: true }
+const tooltip = { trigger: 'axis', axisPointer: { type: 'shadow' } }
+const xAxis = [{ type: 'value' }]
+const yAxis = [{ type: 'category', data: ['搴斾粯璐︽', '搴旀敹璐︽'] }]
+
+onMounted(() => {
+ statisticsReceivablePayable({ type: 1 }).then((res) => {
+ const d = res.data || {}
+ series.value[0].data = [
+ { value: d.payableMoney, itemStyle: { color: '#D97706', borderRadius: [0, 6, 6, 0] } },
+ { value: d.receivableMoney, itemStyle: { color: '#2C51D9', borderRadius: [0, 6, 6, 0] } }
+ ]
+ }).finally(() => { loading.value = false })
+})
+</script>
diff --git a/src/views/home/components/charts/HomeInvoiceChart.vue b/src/views/home/components/charts/HomeInvoiceChart.vue
new file mode 100644
index 0000000..0328779
--- /dev/null
+++ b/src/views/home/components/charts/HomeInvoiceChart.vue
@@ -0,0 +1,87 @@
+<template>
+ <HomePanel title="鍥炴涓庡紑绁ㄨ秼鍔�" subtitle="杩戝崐骞�" :loading="loading">
+ <Echarts
+ :chartStyle="{ width: '100%', height: '270px' }"
+ :grid="grid"
+ :legend="legend"
+ :series="series"
+ :tooltip="tooltip"
+ :xAxis="xAxis"
+ :yAxis="yAxis"
+ style="height: 270px"
+ />
+ </HomePanel>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import * as echarts from 'echarts'
+import Echarts from '@/components/Echarts/echarts.vue'
+import HomePanel from '../HomePanel.vue'
+import { getAmountHalfYear } from '@/api/viewIndex'
+
+const loading = ref(true)
+
+const legend = { show: true, top: 0, right: 0, itemWidth: 10, itemHeight: 10, data: ['寮�绁�', '鍥炴'] }
+const grid = { left: '3%', right: '4%', bottom: '3%', top: 34, containLabel: true }
+const tooltip = { trigger: 'axis' }
+const xAxis = ref([{
+ type: 'category',
+ boundaryGap: false,
+ axisTick: { show: false },
+ data: [],
+ axisLabel: {
+ interval: 0,
+ formatter: (value) => value.replace(/~/g, '\n')
+ }
+}])
+const yAxis = [{ type: 'value', splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)', type: 'dashed' } } }]
+
+const area = (from, to) => ({
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ { offset: 0, color: from },
+ { offset: 1, color: to }
+ ])
+})
+
+const series = ref([
+ {
+ name: '寮�绁�',
+ type: 'line',
+ smooth: true,
+ symbolSize: 6,
+ data: [],
+ lineStyle: { width: 2, color: '#2C51D9' },
+ itemStyle: { color: '#2C51D9' },
+ areaStyle: area('rgba(44, 81, 217, 0.22)', 'rgba(44, 81, 217, 0.02)'),
+ emphasis: { focus: 'series' }
+ },
+ {
+ name: '鍥炴',
+ type: 'line',
+ smooth: true,
+ symbolSize: 6,
+ data: [],
+ lineStyle: { width: 2, color: '#0F9960' },
+ itemStyle: { color: '#0F9960' },
+ areaStyle: area('rgba(15, 153, 96, 0.18)', 'rgba(15, 153, 96, 0.02)'),
+ emphasis: { focus: 'series' }
+ }
+])
+
+onMounted(() => {
+ getAmountHalfYear().then((res) => {
+ const months = []
+ const receipt = []
+ const invoice = []
+ ;(res.data || []).forEach((item) => {
+ months.push(item.month)
+ receipt.push(item.receiptAmount)
+ invoice.push(item.invoiceAmount)
+ })
+ xAxis.value[0].data = months
+ series.value[0].data = invoice
+ series.value[1].data = receipt
+ }).finally(() => { loading.value = false })
+})
+</script>
diff --git a/src/views/home/components/charts/HomeProcessChart.vue b/src/views/home/components/charts/HomeProcessChart.vue
new file mode 100644
index 0000000..08d79d9
--- /dev/null
+++ b/src/views/home/components/charts/HomeProcessChart.vue
@@ -0,0 +1,289 @@
+<template>
+ <HomePanel title="宸ュ簭鐢熶骇缁熻" subtitle="鎶曞叆 路 鎶ュ簾 路 浜у嚭" :loading="loading">
+ <template #extra>
+ <el-button size="small" plain @click="openDialog">閫夋嫨宸ュ簭</el-button>
+ <el-button size="small" plain @click="resetFilter">閲嶇疆</el-button>
+ <el-radio-group v-model="range" size="small" @change="refresh">
+ <el-radio-button :value="1">鏃�</el-radio-button>
+ <el-radio-button :value="2">鍛�</el-radio-button>
+ <el-radio-button :value="3">鏈�</el-radio-button>
+ </el-radio-group>
+ </template>
+
+ <div class="process">
+ <div class="process__chart">
+ <Echarts
+ :chartStyle="{ width: '100%', height: '100%' }"
+ :grid="grid"
+ :series="series"
+ :tooltip="tooltip"
+ :xAxis="xAxis"
+ :yAxis="yAxis"
+ style="height: 100%"
+ @click="handleChartClick"
+ />
+ </div>
+
+ <aside class="process__aside">
+ <div class="process__legend">
+ <span class="process__legend-item"><i class="dot" style="background: #2c51d9"></i>鎶曞叆閲�</span>
+ <span class="process__legend-item"><i class="dot" style="background: #d97706"></i>鎶ュ簾閲�</span>
+ <span class="process__legend-item"><i class="dot" style="background: #0f9960"></i>浜у嚭閲�</span>
+ </div>
+ <div class="process__name">{{ aside.processName }}</div>
+ <div class="process__stat">
+ <span class="process__stat-label">绱鎬绘姇鍏�</span>
+ <span class="process__stat-value app-num">{{ formatAmount(aside.totalInput) }}</span>
+ </div>
+ <div class="process__stat">
+ <span class="process__stat-label">绱鎬绘姤搴�</span>
+ <span class="process__stat-value app-num">{{ formatAmount(aside.totalScrap) }}</span>
+ </div>
+ <div class="process__stat">
+ <span class="process__stat-label">绱鎬讳骇鍑�</span>
+ <span class="process__stat-value app-num">{{ formatAmount(aside.totalOutput) }}</span>
+ </div>
+ </aside>
+ </div>
+
+ <el-dialog v-model="dialogVisible" title="閫夋嫨宸ュ簭" width="500px" append-to-body :close-on-click-modal="false">
+ <div class="dialog-body">
+ <el-checkbox-group v-model="tempIds">
+ <div class="process-grid">
+ <el-checkbox v-for="item in options" :key="item.id" :label="item.id" border>
+ {{ item.name }}
+ </el-checkbox>
+ </div>
+ </el-checkbox-group>
+ </div>
+ <template #footer>
+ <el-button @click="dialogVisible = false">鍙栨秷</el-button>
+ <el-button type="primary" @click="confirmDialog">纭</el-button>
+ </template>
+ </el-dialog>
+ </HomePanel>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import Echarts from '@/components/Echarts/echarts.vue'
+import HomePanel from '../HomePanel.vue'
+import { processDataProductionStatistics } from '@/api/viewIndex'
+import { list } from '@/api/productionManagement/productionProcess'
+
+const loading = ref(true)
+const range = ref(1)
+const chartData = ref([])
+const activeIndex = ref(0)
+
+const options = ref([])
+const selectedIds = ref([])
+const tempIds = ref([])
+const dialogVisible = ref(false)
+
+const xAxis = ref([
+ {
+ nameTextStyle: { color: 'rgba(0,0,0,0.35)', fontSize: 12 },
+ axisLabel: { color: 'rgba(0,0,0,0.45)' },
+ splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)', type: 'dashed' } }
+ }
+])
+
+const yAxis = ref([
+ {
+ type: 'category',
+ axisTick: { show: false },
+ axisLine: { show: false },
+ axisLabel: { color: 'rgba(0,0,0,0.55)' },
+ data: []
+ }
+])
+
+const grid = reactive({ left: 0, right: 60, top: 20, bottom: 20, containLabel: true })
+
+const tooltip = reactive({
+ trigger: 'axis',
+ axisPointer: { type: 'shadow' },
+ formatter: (params) => {
+ const name = params?.[0]?.name ?? ''
+ const lines = (Array.isArray(params) ? params : [])
+ .map((p) => {
+ const box = `<span style="display:inline-block;margin-right:6px;border-radius:2px;width:10px;height:10px;background:${p.color}"></span>`
+ return `${box}${p.seriesName} <b style="float:right;">${Number(p.value || 0).toFixed(2)}</b>`
+ })
+ .join('<br/>')
+ return `<div style="min-width:140px;"><div style="font-weight:700;margin-bottom:6px;">${name}</div>${lines}</div>`
+ }
+})
+
+const series = computed(() => [
+ {
+ name: '鎶曞叆閲�', type: 'bar', stack: 'total', barWidth: 18,
+ itemStyle: { color: '#2C51D9', borderRadius: [6, 0, 0, 6] },
+ data: chartData.value.map((i) => i.input)
+ },
+ {
+ name: '鎶ュ簾閲�', type: 'bar', stack: 'total', barWidth: 18,
+ itemStyle: { color: '#D97706' },
+ data: chartData.value.map((i) => i.scrap)
+ },
+ {
+ name: '浜у嚭閲�', type: 'bar', stack: 'total', barWidth: 18,
+ itemStyle: { color: '#0F9960', borderRadius: [0, 6, 6, 0] },
+ data: chartData.value.map((i) => i.output)
+ }
+])
+
+const aside = computed(() => {
+ const item = chartData.value[activeIndex.value] || {}
+ return {
+ processName: item.name || '鏆傛棤鏁版嵁',
+ totalInput: item.input || 0,
+ totalScrap: item.scrap || 0,
+ totalOutput: item.output || 0
+ }
+})
+
+const formatAmount = (n) =>
+ Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+
+const refresh = () => {
+ return processDataProductionStatistics({
+ type: range.value,
+ processIds: selectedIds.value.length > 0 ? selectedIds.value.join(',') : null
+ }).then((res) => {
+ chartData.value = (res.data || []).map((item) => ({
+ name: item.processName,
+ input: item.totalInput,
+ scrap: item.totalScrap,
+ output: item.totalOutput
+ }))
+ yAxis.value[0].data = chartData.value.map((i) => i.name)
+ activeIndex.value = 0
+ })
+}
+
+const openDialog = () => {
+ tempIds.value = [...selectedIds.value]
+ dialogVisible.value = true
+}
+
+const confirmDialog = () => {
+ selectedIds.value = [...tempIds.value]
+ dialogVisible.value = false
+ refresh()
+}
+
+const resetFilter = () => {
+ selectedIds.value = []
+ tempIds.value = []
+ refresh()
+}
+
+const handleChartClick = (params) => {
+ if (params && params.dataIndex !== undefined) activeIndex.value = params.dataIndex
+}
+
+onMounted(() => {
+ list().then((res) => { options.value = res.data || [] }).catch(() => {})
+ Promise.all([refresh()]).finally(() => { loading.value = false })
+})
+</script>
+
+<style scoped>
+.process {
+ display: flex;
+ gap: 16px;
+ height: 300px;
+}
+
+.process__chart {
+ flex: 1;
+ min-width: 0;
+}
+
+.process__aside {
+ width: 190px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.process__legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 0 0 4px;
+}
+
+.process__legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 12px;
+ color: var(--app-text-tertiary);
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 2px;
+ display: inline-block;
+}
+
+.process__name {
+ padding: 8px 12px;
+ border-radius: var(--app-radius-sm);
+ background: var(--app-primary-soft);
+ color: var(--app-primary);
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.process__stat {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-radius: var(--app-radius-sm);
+ background: var(--app-surface-hover);
+}
+
+.process__stat-label {
+ font-size: 12px;
+ color: var(--app-text-tertiary);
+}
+
+.process__stat-value {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--app-text);
+}
+
+.dialog-body {
+ max-height: 380px;
+ overflow-y: auto;
+}
+
+.process-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+ gap: 12px;
+}
+
+.process-grid :deep(.el-checkbox.is-bordered) {
+ margin-left: 0 !important;
+ width: 100%;
+}
+
+@media (max-width: 1200px) {
+ .process { flex-direction: column; height: auto; }
+ .process__chart { height: 280px; }
+ .process__aside { width: 100%; flex-direction: row; flex-wrap: wrap; }
+ .process__aside > * { flex: 1; min-width: 150px; }
+}
+</style>
diff --git a/src/views/home/components/charts/HomeQualityChart.vue b/src/views/home/components/charts/HomeQualityChart.vue
new file mode 100644
index 0000000..4ff49ad
--- /dev/null
+++ b/src/views/home/components/charts/HomeQualityChart.vue
@@ -0,0 +1,122 @@
+<template>
+ <HomePanel title="璐ㄩ噺缁熻" :loading="loading">
+ <template #extra>
+ <el-radio-group v-model="range" size="small" @change="refresh">
+ <el-radio-button :value="1">鍛�</el-radio-button>
+ <el-radio-button :value="2">鏈�</el-radio-button>
+ <el-radio-button :value="3">瀛e害</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>
diff --git a/src/views/index.vue b/src/views/index.vue
index 4ae5fe4..503d090 100644
--- a/src/views/index.vue
+++ b/src/views/index.vue
@@ -1,1263 +1,113 @@
<template>
- <div class="dashboard">
- <!-- 椤堕儴妯悜涓ゆ爮 -->
- <div class="dashboard-top">
- <!-- 宸︼細浼佷笟淇℃伅+涓夊ぇ鏁版嵁鍗$墖锛堜笂涓嬫帓鍒楋級 -->
- <div class="top-left">
- <div class="company-info">
- <!-- 椤堕儴闂�欐潯 -->
- <div class="welcome-banner">
- <div class="welcome-title">
- <span class="welcome-user">{{ userStore.roleName || '绯荤粺绠$悊鍛�' }}</span>
- <span> 鎮ㄥソ锛佺鎮ㄥ紑蹇冩瘡涓�澶�</span>
- </div>
- <div class="welcome-time">鐧诲綍浜�: {{ userStore.currentLoginTime }}</div>
- </div>
+ <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">瀛e害</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)
- })
- // 姝g‘鍝嶅簲寮忚祴鍊硷細鍒涘缓鏂扮殑 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>
\ No newline at end of file
+
+.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>
diff --git a/src/views/login.vue b/src/views/login.vue
index 6217877..d6c9ef3 100644
--- a/src/views/login.vue
+++ b/src/views/login.vue
@@ -1,232 +1,332 @@
-<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>
diff --git a/src/views/login/components/LoginBrand.vue b/src/views/login/components/LoginBrand.vue
new file mode 100644
index 0000000..3c7c097
--- /dev/null
+++ b/src/views/login/components/LoginBrand.vue
@@ -0,0 +1,230 @@
+<template>
+ <aside class="brand">
+ <!-- 鍝佺墝鍦烘櫙鑳屾櫙 -->
+ <div class="brand__bg"></div>
+
+ <!-- 宸︿笂浼佷笟 Logo -->
+ <div class="brand__logo">
+ <img :src="logo" alt="鍋ラ娇榻跨鍣ㄦ潗" />
+ </div>
+
+ <div class="brand__hero">
+ <h1 class="brand__headline">涓撲笟涓撴敞 <em>鍋ュ悍榻跨</em></h1>
+ <p class="brand__desc">涓哄叏鐞冨彛鑵斿尰鐤楁満鏋勬彁渚涢珮鍝佽川鐨勯娇绉戝櫒鏉愪笌瑙e喅鏂规</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: '鍔╁姏鍙h厰鍋ュ悍浜嬩笟' }
+]
+</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>
diff --git a/vite.config.js b/vite.config.js
index dc687a8..5c661e5 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -8,7 +8,7 @@
const { VITE_APP_ENV } = env;
const baseUrl =
env.VITE_APP_ENV === "development"
- ? "http://1.15.17.182:9003"
+ ? "http://localhost:7003"
: env.VITE_BASE_API;
const javaUrl =
env.VITE_APP_ENV === "development"
--
Gitblit v1.9.3