feat(ui): refresh crm shell and customer workspace
CRM/ERP CI / Backend Tests (Python) (push) Successful in 4m12s
CRM/ERP CI / Frontend Build (Vue3 + Vite) (push) Successful in 1m49s
CRM/ERP CI / Docker Compose Build (push) Successful in 1m27s

This commit is contained in:
2026-05-11 21:42:04 +08:00
parent 0fe88c1eb8
commit 7df1045e77
16 changed files with 1374 additions and 290 deletions
+7
View File
@@ -9,6 +9,13 @@ test.describe('客户管理', () => {
await page.waitForLoadState('networkidle');
});
test('新版客户页信息架构可见', async ({ page }) => {
await expect(page.getByTestId('customers-page')).toBeVisible({ timeout: 5000 });
await expect(page.getByRole('heading', { name: '客户管理' })).toBeVisible();
await expect(page.getByTestId('customers-summary')).toBeVisible();
await expect(page.getByTestId('customers-table-card')).toBeVisible();
});
test('客户列表正确加载', async ({ page }) => {
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
await expect(page.getByRole('button', { name: '新增客户' })).toBeVisible();
+3 -18
View File
@@ -5,21 +5,6 @@
*/
</script>
<template>
<router-view />
</template>
<style>
/* 全局基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', 'PingFang SC', -apple-system, sans-serif;
background-color: #f5f7fa;
color: #303133;
}
</style>
<template>
<router-view />
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
defineProps<{
title?: string
description?: string
}>()
</script>
<template>
<section class="crm-table-shell crm-glass-card">
<header v-if="title || description || $slots.header" class="crm-table-shell__header">
<div v-if="title || description">
<h2 v-if="title" class="crm-table-shell__title">{{ title }}</h2>
<p v-if="description" class="crm-table-shell__description">{{ description }}</p>
</div>
<div v-if="$slots.header" class="crm-table-shell__header-side">
<slot name="header" />
</div>
</header>
<div class="crm-table-shell__body">
<slot />
</div>
<footer v-if="$slots.footer" class="crm-table-shell__footer">
<slot name="footer" />
</footer>
</section>
</template>
<style scoped>
.crm-table-shell {
overflow: hidden;
}
.crm-table-shell__header,
.crm-table-shell__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
}
.crm-table-shell__header {
border-bottom: 1px solid var(--crm-border);
}
.crm-table-shell__footer {
border-top: 1px solid var(--crm-border);
}
.crm-table-shell__title {
font-size: 18px;
font-weight: 700;
color: var(--crm-text);
}
.crm-table-shell__description {
margin-top: 4px;
font-size: 13px;
color: var(--crm-text-subtle);
}
.crm-table-shell__body {
padding: 10px 12px 16px;
}
@media (max-width: 768px) {
.crm-table-shell__header,
.crm-table-shell__footer {
padding: 18px 16px;
}
.crm-table-shell__body {
padding: 8px 8px 14px;
}
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
defineProps<{
label: string
value: string | number
helper?: string
tone?: 'primary' | 'success' | 'warning' | 'danger'
}>()
</script>
<template>
<article class="crm-kpi-card crm-glass-card" :data-tone="tone || 'primary'">
<p class="crm-kpi-card__label">{{ label }}</p>
<div class="crm-kpi-card__value">{{ value }}</div>
<p v-if="helper" class="crm-kpi-card__helper">{{ helper }}</p>
</article>
</template>
<style scoped>
.crm-kpi-card {
min-height: 132px;
padding: 20px;
}
.crm-kpi-card__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--crm-text-subtle);
}
.crm-kpi-card__value {
margin-top: 16px;
font-size: clamp(28px, 3vw, 38px);
line-height: 1;
font-weight: 700;
color: var(--crm-text);
}
.crm-kpi-card__helper {
margin-top: 10px;
font-size: 13px;
color: var(--crm-text-muted);
}
.crm-kpi-card[data-tone='primary'] .crm-kpi-card__value {
color: var(--crm-primary);
}
.crm-kpi-card[data-tone='success'] .crm-kpi-card__value {
color: var(--crm-success);
}
.crm-kpi-card[data-tone='warning'] .crm-kpi-card__value {
color: var(--crm-warning);
}
.crm-kpi-card[data-tone='danger'] .crm-kpi-card__value {
color: var(--crm-danger);
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
defineProps<{
title: string
description?: string
}>()
</script>
<template>
<section class="crm-page-shell crm-page">
<header class="crm-page-shell__header">
<div class="crm-page-shell__copy">
<p class="crm-page-shell__eyebrow">SHBL CRM Workspace</p>
<h1 class="crm-page-shell__title">{{ title }}</h1>
<p v-if="description" class="crm-page-shell__description">{{ description }}</p>
</div>
<div v-if="$slots.actions" class="crm-page-shell__actions">
<slot name="actions" />
</div>
</header>
<section v-if="$slots.stats" class="crm-page-shell__stats">
<slot name="stats" />
</section>
<section v-if="$slots.filters" class="crm-page-shell__filters crm-glass-card">
<slot name="filters" />
</section>
<section class="crm-page-shell__body">
<slot />
</section>
</section>
</template>
<style scoped>
.crm-page-shell__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.crm-page-shell__copy {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.crm-page-shell__eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--crm-text-subtle);
}
.crm-page-shell__title {
font-size: clamp(30px, 4vw, 48px);
line-height: 1.04;
font-weight: 700;
color: var(--crm-text);
}
.crm-page-shell__description {
max-width: 680px;
font-size: 15px;
line-height: 1.6;
color: var(--crm-text-muted);
}
.crm-page-shell__actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.crm-page-shell__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.crm-page-shell__filters {
padding: 18px 20px;
}
.crm-page-shell__body {
min-width: 0;
}
@media (max-width: 1100px) {
.crm-page-shell__header {
flex-direction: column;
}
.crm-page-shell__actions {
width: 100%;
justify-content: flex-start;
}
.crm-page-shell__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.crm-page-shell__stats {
grid-template-columns: 1fr;
}
}
</style>
+184 -49
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue'
import { ref, reactive, nextTick, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -32,6 +32,8 @@ const isCollapse = ref(false)
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const routeTitle = computed(() => (route.meta?.title as string) || '工作台')
const userInitial = computed(() => (userStore.realName || userStore.username || 'A').trim().charAt(0).toUpperCase())
const toggleSidebar = () => {
isCollapse.value = !isCollapse.value
@@ -112,20 +114,21 @@ const handleSwitchCompany = (companyId: string) => {
<template>
<el-container class="layout-container">
<!-- 左侧菜单 -->
<el-aside :width="isCollapse ? '64px' : '240px'" class="aside">
<el-aside :width="isCollapse ? '84px' : '272px'" class="aside">
<div class="logo">
<span v-show="!isCollapse" class="logo-text"><strong>SHBL-ERP</strong></span>
<div class="logo-mark">S</div>
<span v-show="!isCollapse" class="logo-copy">
<span class="logo-kicker">SHBL Workspace</span>
<strong class="logo-text">SHBL CRM</strong>
</span>
<span v-show="isCollapse" class="logo-icon">S</span>
</div>
<el-menu
:default-active="route.path"
class="el-menu-vertical"
class="el-menu-vertical crm-aside-menu"
:collapse="isCollapse"
router
unique-opened
background-color="#001529"
text-color="#a6adb4"
active-text-color="#fff"
>
<el-menu-item index="/">
<el-icon><House /></el-icon>
@@ -212,14 +215,13 @@ const handleSwitchCompany = (companyId: string) => {
<!-- 顶栏 -->
<el-header class="header">
<div class="header-left">
<el-icon class="toggle-icon" @click="toggleSidebar">
<button class="toggle-button" type="button" @click="toggleSidebar">
<component :is="isCollapse ? Expand : Fold" />
</el-icon>
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ (route.meta?.title as string) || route.name }}</el-breadcrumb-item>
</el-breadcrumb>
</button>
<div class="header-copy">
<span class="header-kicker">当前页面</span>
<strong class="header-title">{{ routeTitle }}</strong>
</div>
</div>
<div class="header-right">
<!-- 公司视角切换 -->
@@ -252,7 +254,7 @@ const handleSwitchCompany = (companyId: string) => {
<!-- 用户头像 -->
<el-dropdown @command="handleCommand">
<span class="user-dropdown">
<el-avatar :size="30" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
<el-avatar :size="34" class="user-avatar">{{ userInitial }}</el-avatar>
<span class="username">{{ userStore.realName || userStore.username || 'Admin' }}</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
@@ -304,110 +306,223 @@ const handleSwitchCompany = (companyId: string) => {
.layout-container {
height: 100vh;
width: 100vw;
background: transparent;
}
.aside {
background-color: #001529;
transition: width 0.3s;
display: flex;
flex-direction: column;
padding: 18px 14px 18px 18px;
background: rgba(255, 255, 255, 0.72);
border-right: 1px solid var(--crm-border);
backdrop-filter: blur(20px);
box-shadow: 20px 0 50px rgba(15, 23, 42, 0.04);
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
color: #fff;
background-color: #002140;
display: flex;
align-items: center;
gap: 14px;
min-height: 72px;
margin-bottom: 18px;
padding: 12px 14px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(0, 95, 184, 0.12), rgba(255, 255, 255, 0.75));
border: 1px solid rgba(0, 95, 184, 0.12);
overflow: hidden;
white-space: nowrap;
}
.logo-mark,
.logo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 14px;
background: linear-gradient(135deg, var(--crm-primary), var(--crm-primary-strong));
color: #fff;
font-size: 18px;
font-weight: 700;
box-shadow: 0 14px 24px rgba(0, 95, 184, 0.22);
}
.logo-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.logo-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--crm-text-subtle);
}
.logo-text {
font-size: 18px;
letter-spacing: 1px;
}
.logo-icon {
font-size: 20px;
font-weight: bold;
font-size: 22px;
line-height: 1.1;
color: var(--crm-text);
}
.el-menu-vertical {
flex: 1;
border-right: none;
background: transparent;
}
/* Ensure sub-menu titles have a nice color change on hover */
:deep(.el-sub-menu__title:hover) {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.el-menu-item:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
:deep(.crm-aside-menu .el-menu) {
background: transparent;
border-right: none;
}
.el-menu-item.is-active {
background-color: var(--el-color-primary) !important;
:deep(.crm-aside-menu .el-sub-menu__title),
:deep(.crm-aside-menu .el-menu-item) {
height: 48px;
margin-bottom: 6px;
border-radius: 14px;
color: var(--crm-text-muted);
font-weight: 500;
}
:deep(.crm-aside-menu .el-sub-menu__title:hover),
:deep(.crm-aside-menu .el-menu-item:hover) {
color: var(--crm-text);
background: rgba(255, 255, 255, 0.72) !important;
}
:deep(.crm-aside-menu .el-menu-item.is-active) {
color: var(--crm-primary) !important;
background: rgba(0, 95, 184, 0.1) !important;
box-shadow: inset 3px 0 0 var(--crm-primary);
}
:deep(.crm-aside-menu .el-sub-menu .el-menu-item) {
min-width: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #dcdfe6;
background-color: #fff;
height: 60px;
padding: 0 20px;
border-bottom: 1px solid rgba(86, 99, 122, 0.12);
background: rgba(255, 255, 255, 0.76);
backdrop-filter: blur(18px);
height: 76px;
padding: 0 28px;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.toggle-icon {
font-size: 20px;
.toggle-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 1px solid var(--crm-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.85);
color: var(--crm-text-muted);
cursor: pointer;
margin-right: 20px;
transition: all 0.2s ease;
}
.toggle-button:hover {
color: var(--crm-primary);
border-color: rgba(0, 95, 184, 0.25);
transform: translateY(-1px);
}
.header-copy {
display: flex;
flex-direction: column;
}
.header-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--crm-text-subtle);
}
.header-title {
font-size: 18px;
color: var(--crm-text);
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.user-dropdown {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 8px 12px;
border-radius: 14px;
transition: background-color 0.2s ease;
}
.user-dropdown:hover {
background: rgba(255, 255, 255, 0.72);
}
.username {
margin-left: 8px;
font-size: 14px;
color: var(--crm-text);
font-weight: 600;
}
.company-dropdown {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 14px;
color: #606266;
color: var(--crm-text-muted);
padding: 8px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--crm-border);
}
.company-dropdown:hover {
color: var(--el-color-primary);
}
.company-name {
margin: 0 4px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-avatar {
background: linear-gradient(135deg, var(--crm-primary), #2f80d1);
color: #fff;
font-weight: 700;
}
.is-active-company {
color: var(--el-color-primary);
font-weight: 600;
}
.main {
background-color: #f0f2f5;
padding: 20px;
background: transparent;
padding: var(--crm-page-padding);
overflow-y: auto;
}
@@ -426,4 +541,24 @@ const handleSwitchCompany = (companyId: string) => {
opacity: 0;
transform: translateX(30px);
}
@media (max-width: 900px) {
.header {
height: auto;
min-height: 76px;
padding: 14px 16px;
align-items: flex-start;
gap: 12px;
}
.header-right {
flex-wrap: wrap;
justify-content: flex-end;
}
.company-name,
.username {
max-width: 100px;
}
}
</style>
+11 -9
View File
@@ -3,15 +3,17 @@
* 初始化 Vue3 + Pinia + Router + Element Plus
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
+122
View File
@@ -0,0 +1,122 @@
html,
body,
#app {
min-height: 100%;
}
body {
font-family: var(--crm-font-sans);
background:
radial-gradient(circle at top left, rgba(0, 95, 184, 0.08), transparent 28%),
linear-gradient(180deg, #f8f8fb 0%, #f3f4f7 100%);
color: var(--crm-text);
}
a {
color: inherit;
}
button,
input,
textarea,
select {
font: inherit;
}
.crm-page {
display: flex;
flex-direction: column;
gap: var(--crm-gap-lg);
}
.crm-glass-card {
background: var(--crm-surface);
border: 1px solid var(--crm-border);
border-radius: var(--crm-radius-lg);
box-shadow: var(--crm-shadow-sm);
backdrop-filter: blur(18px);
}
.crm-pill {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.crm-pill.is-primary {
color: var(--crm-primary);
background: var(--crm-primary-soft);
}
.crm-pill.is-warning {
color: var(--crm-warning);
background: var(--crm-warning-soft);
}
.crm-pill.is-danger {
color: var(--crm-danger);
background: var(--crm-danger-soft);
}
.crm-pill.is-success {
color: var(--crm-success);
background: var(--crm-success-soft);
}
.el-button {
font-weight: 600;
}
.el-button--primary {
box-shadow: 0 10px 20px rgba(0, 95, 184, 0.16);
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
border-radius: 12px;
box-shadow: none;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused,
.el-textarea__inner:focus {
box-shadow: 0 0 0 3px rgba(0, 95, 184, 0.14);
}
.el-card {
border-radius: var(--crm-radius-lg);
border: 1px solid var(--crm-border);
box-shadow: var(--crm-shadow-sm);
}
.el-table {
--el-table-border-color: rgba(86, 99, 122, 0.12);
--el-table-header-bg-color: rgba(248, 249, 252, 0.85);
--el-table-row-hover-bg-color: rgba(0, 95, 184, 0.04);
border-radius: 16px;
overflow: hidden;
}
.el-dialog {
border-radius: 20px;
}
@media (max-width: 1280px) {
:root {
--crm-page-padding: 20px;
}
}
@media (max-width: 768px) {
:root {
--crm-page-padding: 16px;
--crm-gap-lg: 18px;
}
}
+55
View File
@@ -0,0 +1,55 @@
:root {
color-scheme: light;
--crm-font-sans: "PingFang SC", "Microsoft YaHei", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--crm-bg: #f5f5f7;
--crm-bg-soft: #fbfbfd;
--crm-surface: rgba(255, 255, 255, 0.9);
--crm-surface-solid: #ffffff;
--crm-surface-muted: #f2f4f8;
--crm-surface-alt: #eceff5;
--crm-border: rgba(86, 99, 122, 0.15);
--crm-border-strong: rgba(78, 92, 116, 0.24);
--crm-text: #191c22;
--crm-text-muted: #5f6776;
--crm-text-subtle: #7d8594;
--crm-primary: #005fb8;
--crm-primary-strong: #004991;
--crm-primary-soft: rgba(0, 95, 184, 0.1);
--crm-success: #0c8f63;
--crm-success-soft: rgba(12, 143, 99, 0.12);
--crm-warning: #a76600;
--crm-warning-soft: rgba(167, 102, 0, 0.12);
--crm-danger: #c13f32;
--crm-danger-soft: rgba(193, 63, 50, 0.12);
--crm-shadow-sm: 0 10px 30px rgba(15, 23, 42, 0.04);
--crm-shadow-md: 0 20px 45px rgba(15, 23, 42, 0.08);
--crm-radius-sm: 8px;
--crm-radius-md: 12px;
--crm-radius-lg: 18px;
--crm-page-padding: 28px;
--crm-gap-sm: 12px;
--crm-gap-md: 18px;
--crm-gap-lg: 24px;
--el-color-primary: var(--crm-primary);
--el-color-primary-light-3: #2d7fd0;
--el-color-primary-light-5: #5e9ddd;
--el-color-primary-light-7: #9fc4e9;
--el-color-primary-light-8: #c4daf1;
--el-color-primary-light-9: #e8f1fa;
--el-color-primary-dark-2: var(--crm-primary-strong);
--el-bg-color: var(--crm-surface-solid);
--el-bg-color-page: var(--crm-bg);
--el-fill-color-blank: var(--crm-surface-solid);
--el-border-color: rgba(86, 99, 122, 0.18);
--el-border-color-light: rgba(86, 99, 122, 0.12);
--el-text-color-primary: var(--crm-text);
--el-text-color-regular: var(--crm-text-muted);
--el-text-color-secondary: var(--crm-text-subtle);
--el-font-family: var(--crm-font-sans);
--el-border-radius-base: var(--crm-radius-sm);
--el-border-radius-small: 6px;
--el-border-radius-round: 999px;
--el-box-shadow-light: var(--crm-shadow-sm);
--el-mask-color-extra-light: rgba(245, 245, 247, 0.85);
}
+309 -88
View File
@@ -6,13 +6,15 @@
* PUT /api/customers/{id} (编辑)
* DELETE /api/customers/{id} (软删除/归档)
*/
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Search, Plus, View, Edit, Box, Upload, Download, Sort } from '@element-plus/icons-vue'
import { Search, Plus, View, Edit, Box, Upload, Download, Sort, RefreshRight, FolderOpened } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
import PageShell from '@/components/PageShell.vue'
import DataTableShell from '@/components/DataTableShell.vue'
import KpiCard from '@/components/KpiCard.vue'
const userStore = useUserStore()
const isAdmin = computed(() => userStore.userInfo?.data_scope === 'all' || (userStore.userInfo?.role_name || '').toLowerCase() === 'admin')
@@ -33,6 +35,38 @@ const customerData = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const summaryCards = computed(() => {
const archivedCount = customerData.value.filter((item) => item.is_deleted).length
const keyCount = customerData.value.filter((item) => item.level === 'A').length
const activeCount = customerData.value.length - archivedCount
return [
{
label: '结果总数',
value: total.value,
helper: searchForm.keyword ? `关键词:${searchForm.keyword}` : '当前筛选范围内的客户总量',
tone: 'primary' as const,
},
{
label: '当前页活跃客户',
value: activeCount,
helper: '排除已归档客户后的可跟进对象',
tone: 'success' as const,
},
{
label: 'A级重点客户',
value: keyCount,
helper: '优先关注大客户与关键机会',
tone: 'warning' as const,
},
{
label: '已归档客户',
value: archivedCount,
helper: searchForm.showArchived ? '当前结果已包含归档记录' : '打开归档筛选后可查看',
tone: 'danger' as const,
},
]
})
// --- 拉取客户列表 ---
const fetchCustomers = async () => {
@@ -236,6 +270,19 @@ const getLevelLabel = (level: string) => {
return level || '-'
}
const formatCreatedAt = (value?: string) => {
if (!value) return '-'
return value.replace('T', ' ').slice(0, 16)
}
const getCustomerStatusLabel = (row: any) => {
return row.is_deleted ? '已归档' : '活跃'
}
const getCustomerStatusClass = (row: any) => {
return row.is_deleted ? 'is-danger' : 'is-primary'
}
// --- 导入 / 导出 ---
const importDialogVisible = ref(false)
@@ -319,87 +366,159 @@ onMounted(() => {
</script>
<template>
<div class="customer-list-container">
<!-- 1. 顶部高级检索区 -->
<el-card shadow="never" class="filter-section">
<div class="filter-wrapper">
<el-form :inline="true" :model="searchForm" class="filter-form">
<el-form-item label="客户名称">
<el-input v-model="searchForm.keyword" placeholder="请输入名称/拼音" clearable @keyup.enter="handleSearch" />
<PageShell
title="客户管理"
description="围绕客户全生命周期组织线索、沟通、成交和归档信息,让销售、财务与管理层都能更快读懂客户状态。"
data-testid="customers-page"
>
<template #actions>
<el-button :icon="RefreshRight" @click="fetchCustomers">刷新</el-button>
<el-button type="warning" :icon="Upload" @click="importDialogVisible = true">导入客户</el-button>
<el-button v-if="isAdmin" :icon="Download" @click="handleExport">导出</el-button>
<el-button type="primary" :icon="Plus" @click="handleAddCustomer">新增客户</el-button>
</template>
<template #stats>
<div class="customers-summary" data-testid="customers-summary">
<KpiCard
v-for="item in summaryCards"
:key="item.label"
:label="item.label"
:value="item.value"
:helper="item.helper"
:tone="item.tone"
/>
</div>
</template>
<template #filters>
<div class="customers-toolbar">
<div class="customers-toolbar__copy">
<p class="customers-toolbar__title">客户索引</p>
<p class="customers-toolbar__subtitle">优先处理重点客户活跃跟进和归档恢复不再把关键信息埋在表格里</p>
</div>
<el-form :model="searchForm" class="customers-filters" @submit.prevent>
<el-form-item>
<el-input
v-model="searchForm.keyword"
placeholder="搜索客户名称、联系人或拼音"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="searchForm.level" placeholder="全部分级" clearable style="width: 150px">
<el-form-item>
<el-select v-model="searchForm.level" placeholder="全部分级" clearable>
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item>
<el-switch v-model="searchForm.showArchived" active-text="显示已归档" @change="handleSearch" style="margin-right: 10px" />
<el-form-item class="customers-filters__switch">
<el-switch v-model="searchForm.showArchived" active-text="包含已归档" @change="handleSearch" />
</el-form-item>
<el-form-item class="customers-filters__action">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
<div class="action-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button type="warning" :icon="Upload" @click="importDialogVisible = true">导入客户</el-button>
<el-button v-if="isAdmin" type="info" :icon="Download" @click="handleExport">导出</el-button>
<el-button type="success" :icon="Plus" @click="handleAddCustomer">新增客户</el-button>
</div>
</div>
</el-card>
</template>
<!-- 2. 核心数据表格 -->
<el-card shadow="never" class="table-section">
<el-table :data="customerData" stripe border style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="客户名称" min-width="220" show-overflow-tooltip>
<DataTableShell
title="客户列表"
description="保留现有业务操作链路,同时把浏览、筛选、识别重点客户的效率做上去。"
data-testid="customers-table-card"
>
<template #header>
<div class="customers-table-meta">
<span class="crm-pill is-primary">
<el-icon><FolderOpened /></el-icon>
当前页 {{ customerData.length }}
</span>
</div>
</template>
<el-table :data="customerData" style="width: 100%" v-loading="loading" empty-text="暂无客户数据">
<el-table-column prop="name" label="客户信息" min-width="280" show-overflow-tooltip>
<template #default="scope">
<span class="customer-name-bold">{{ scope.row.name }}</span>
<div class="customer-cell">
<div class="customer-cell__avatar">{{ (scope.row.name || '客').slice(0, 1) }}</div>
<div class="customer-cell__copy">
<span class="customer-cell__name">{{ scope.row.name || '-' }}</span>
<span class="customer-cell__meta">{{ scope.row.contact || '暂无联系人' }} · {{ scope.row.phone || '未录入电话' }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="客户级别" width="120" align="center">
<el-table-column label="状态" width="120" align="center">
<template #default="scope">
<el-tag :type="getLevelType(scope.row.level)" effect="light">
<span class="crm-pill" :class="getCustomerStatusClass(scope.row)">
{{ getCustomerStatusLabel(scope.row) }}
</span>
</template>
</el-table-column>
<el-table-column label="客户级别" width="130" align="center">
<template #default="scope">
<el-tag :type="getLevelType(scope.row.level)" effect="light" round>
{{ getLevelLabel(scope.row.level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="industry" label="所属行业" min-width="160" show-overflow-tooltip />
<el-table-column prop="contact" label="联系人" width="120" />
<el-table-column prop="phone" label="联系电话" width="140" />
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="280" fixed="right">
<el-table-column prop="industry" label="所属行业" min-width="160" show-overflow-tooltip>
<template #default="scope">
<el-button type="primary" link :icon="View" @click="viewDetails(scope.row)">查看档案</el-button>
<template v-if="!scope.row.is_deleted">
<el-button type="primary" link :icon="Edit" @click="editCustomer(scope.row)">编辑</el-button>
<el-button v-if="isAdmin" type="warning" link :icon="Sort" @click="openTransferDialog(scope.row)">转移</el-button>
<el-button type="danger" link :icon="Box" @click="archiveCustomer(scope.row)">归档</el-button>
</template>
<el-button v-else type="success" link @click="restoreCustomer(scope.row)">恢复</el-button>
<span class="table-muted">{{ scope.row.industry || '未填写行业' }}</span>
</template>
</el-table-column>
<el-table-column prop="address" label="所在地区" min-width="190" show-overflow-tooltip>
<template #default="scope">
<span class="table-muted">{{ scope.row.address || '未填写地址' }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170">
<template #default="scope">
<span class="table-muted">{{ formatCreatedAt(scope.row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right" align="right">
<template #default="scope">
<div class="row-actions">
<el-button type="primary" link :icon="View" @click="viewDetails(scope.row)">查看档案</el-button>
<template v-if="!scope.row.is_deleted">
<el-button type="primary" link :icon="Edit" @click="editCustomer(scope.row)">编辑</el-button>
<el-button v-if="isAdmin" type="warning" link :icon="Sort" @click="openTransferDialog(scope.row)">转移</el-button>
<el-button type="danger" link :icon="Box" @click="archiveCustomer(scope.row)">归档</el-button>
</template>
<el-button v-else type="success" link @click="restoreCustomer(scope.row)">恢复</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 3. 分页 -->
<div class="pagination-section">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
background
layout="total, prev, pager, next, jumper"
:total="total"
@current-change="handlePageChange"
@size-change="handlePageChange"
/>
</div>
</el-card>
<template #footer>
<div class="customers-pagination">
<span class="customers-pagination__summary">显示第 {{ customerData.length ? (currentPage - 1) * pageSize + 1 : 0 }} {{ (currentPage - 1) * pageSize + customerData.length }} {{ total }} </span>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
background
layout="prev, pager, next, jumper"
:total="total"
@current-change="handlePageChange"
@size-change="handlePageChange"
/>
</div>
</template>
</DataTableShell>
<!-- 4. 新增客户弹窗 -->
<el-dialog v-model="addDialogVisible" title="新增客户开户" width="560px" destroy-on-close>
@@ -582,56 +701,158 @@ onMounted(() => {
<el-button type="primary" :loading="transferSubmitting" @click="submitTransfer">确认转移</el-button>
</template>
</el-dialog>
</div>
</PageShell>
</template>
<style scoped>
.customer-list-container {
display: flex;
flex-direction: column;
.customers-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.customers-toolbar {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr);
gap: 20px;
align-items: center;
}
/* 顶部检索区 */
.filter-section {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.customers-toolbar__copy {
min-width: 0;
}
.filter-wrapper {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: flex-start;
.customers-toolbar__title {
font-size: 20px;
font-weight: 700;
color: var(--crm-text);
}
.filter-form .el-form-item {
.customers-toolbar__subtitle {
margin-top: 6px;
font-size: 14px;
line-height: 1.6;
color: var(--crm-text-muted);
}
.customers-filters {
display: grid;
grid-template-columns: minmax(240px, 1.3fr) 160px auto auto;
gap: 12px;
align-items: center;
}
:deep(.customers-filters .el-form-item) {
margin-bottom: 0;
margin-right: 20px;
}
.action-buttons {
.customers-filters__switch {
justify-self: flex-start;
}
.customers-filters__action {
justify-self: flex-end;
}
.customers-table-meta {
display: flex;
align-items: center;
gap: 10px;
}
/* 数据表格区 */
.table-section {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.customer-cell {
display: flex;
align-items: center;
gap: 14px;
}
.customer-name-bold {
font-weight: bold;
color: #303133;
.customer-cell__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(0, 95, 184, 0.12), rgba(0, 95, 184, 0.04));
color: var(--crm-primary);
font-size: 18px;
font-weight: 700;
border: 1px solid rgba(0, 95, 184, 0.12);
}
/* 分页 */
.pagination-section {
margin-top: 20px;
.customer-cell__copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.customer-cell__name {
font-size: 16px;
font-weight: 700;
color: var(--crm-text);
}
.customer-cell__meta,
.table-muted {
font-size: 13px;
color: var(--crm-text-muted);
}
.row-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 2px 10px;
}
.customers-pagination {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.customers-pagination__summary {
font-size: 13px;
color: var(--crm-text-muted);
}
:deep(.el-table .el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
@media (max-width: 1280px) {
.customers-toolbar {
grid-template-columns: 1fr;
}
.customers-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.customers-filters__action {
justify-self: flex-start;
}
}
@media (max-width: 900px) {
.customers-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.customers-pagination {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 640px) {
.customers-summary,
.customers-filters {
grid-template-columns: 1fr;
}
}
</style>
+24 -97
View File
@@ -3,6 +3,9 @@ import { ref, onMounted } from 'vue'
import { Plus, Van, Box, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import request from '@/api/request'
import PageShell from '@/components/PageShell.vue'
import KpiCard from '@/components/KpiCard.vue'
import DataTableShell from '@/components/DataTableShell.vue'
const router = useRouter()
@@ -29,110 +32,34 @@ onMounted(fetchStats)
</script>
<template>
<div class="dashboard-container">
<!-- 顶部快捷操作 -->
<div class="quick-actions">
<PageShell
title="工作台"
description="把订单、发货、库存、收入和最近动态拉到同一视角里,优先帮助业务负责人快速判断今天要推进什么。"
>
<template #actions>
<el-button type="primary" :icon="Plus" @click="router.push('/orders')">新建订单</el-button>
<el-button type="success" :icon="Van" @click="router.push('/shipping')">安排发货</el-button>
<el-button type="warning" :icon="Box" @click="router.push('/products')">库存入库</el-button>
<el-button type="info" :icon="EditPen" @click="router.push('/logs')">写销售日志</el-button>
</div>
<el-button :icon="EditPen" @click="router.push('/logs')">写销售日志</el-button>
</template>
<!-- 中部核心数据 KPI -->
<el-row :gutter="20" class="kpi-cards">
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">本月新增订单</div>
<div class="kpi-value">{{ stats.orders_count }} <span class="kpi-unit"></span></div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">待出库发货</div>
<div class="kpi-value" style="color: #e6a23c;">{{ stats.pending_shipping }} <span class="kpi-unit"></span></div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">库存预警 SKU</div>
<div class="kpi-value" style="color: #f56c6c;">{{ stats.warning_skus }} <span class="kpi-unit"></span></div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">本月预计营收</div>
<div class="kpi-value" style="color: #67c23a;">{{ formatMoney(stats.monthly_revenue) }}</div>
</el-card>
</el-col>
</el-row>
<template #stats>
<KpiCard label="本月新增订单" :value="`${stats.orders_count} 单`" helper="聚焦新增成交和推进节奏" tone="primary" />
<KpiCard label="待出库发货" :value="`${stats.pending_shipping} 单`" helper="交付风险和履约节奏" tone="warning" />
<KpiCard label="库存预警 SKU" :value="`${stats.warning_skus} 个`" helper="优先处理潜在断货项" tone="danger" />
<KpiCard label="本月预计营收" :value="formatMoney(stats.monthly_revenue)" helper="结合当前订单的收入预估" tone="success" />
</template>
<!-- 底部最新动态 -->
<el-card shadow="never" class="recent-activities">
<template #header>
<div class="card-header">
<span>近期业务动态</span>
</div>
</template>
<el-empty description="暂无业务动态数据,请先录入订单和日志" />
</el-card>
</div>
<DataTableShell title="近期业务动态" description="后续这里可以继续挂接订单进展、客户跟进和 AI 复盘提醒。">
<div class="dashboard-empty" v-loading="loading">
<el-empty description="暂无业务动态数据,请先录入订单和日志" />
</div>
</DataTableShell>
</PageShell>
</template>
<style scoped>
.dashboard-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.quick-actions {
display: flex;
gap: 12px;
background-color: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.kpi-cards .el-col {
margin-bottom: 20px;
}
.kpi-card {
height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 8px;
border: none;
}
.kpi-title {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
}
.kpi-value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.kpi-unit {
font-size: 14px;
font-weight: normal;
color: #606266;
}
.recent-activities {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.card-header {
font-weight: bold;
font-size: 16px;
.dashboard-empty {
min-height: 240px;
}
</style>
+8 -7
View File
@@ -7,12 +7,13 @@
"DOM",
"DOM.Iterable"
],
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
@@ -32,4 +33,4 @@
"path": "./tsconfig.node.json"
}
]
}
}
+12 -10
View File
@@ -1,16 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"strict": true,
"skipLibCheck": true
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./.tsbuild/node",
"tsBuildInfoFile": "./.tsbuild/tsconfig.node.tsbuildinfo",
"strict": true,
"skipLibCheck": true
},
"include": [
"vite.config.ts"
]
}
}
+13 -12
View File
@@ -1,15 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'), // @ 指向 src 目录
},
},
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const srcPath = decodeURIComponent(new URL('./src', import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, '$1')
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': srcPath, // @ 指向 src 目录
},
},
server: {
port: 5173,
// 开发环境代理:将 /api 请求转发到后端,避免 CORS 问题