Merge branch 'main' into develop
# Conflicts: # backend/app/api/key_visits.py # backend/app/api/mini_business.py # backend/app/api/visits.py # backend/app/api/work_plans.py # backend/app/main.py # backend/app/models/__init__.py # backend/app/schemas/key_visit.py # backend/app/schemas/mini_business.py # backend/app/schemas/work_plan.py # backend/app/services/light_board.py # frontend/src/components/DesktopLayout.vue # frontend/src/stores/theme.ts # frontend/src/views/desktop/ManagerWorkspace.vue # frontend/src/views/desktop/WorkPlans.vue # frontend/src/views/mobile/KeyVisitForm.vue # frontend/src/views/mobile/LeaveForm.vue # frontend/src/views/mobile/PlansList.vue # frontend/src/views/mobile/VisitForm.vue # frontend/src/views/mobile/WorkPlanForm.vue
This commit is contained in:
@@ -6,6 +6,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>企迹 - 政企周报管理系统</title>
|
||||
|
||||
<!-- Apply saved theme before page render to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme') || 'editorial';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Preconnect to Google Fonts origins for faster font loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
@@ -177,6 +177,215 @@ const isMobile = computed(() => {
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Minimal Dark — 极简深色
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="minimal-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #E5E5E5;
|
||||
--ink-light: #CCCCCC;
|
||||
--ink-dark: #FFFFFF;
|
||||
--vermilion: #F87171;
|
||||
--vermilion-light: #FCA5A5;
|
||||
--vermilion-dark: #EF4444;
|
||||
--gold: #A78BFA;
|
||||
--gold-light: #C4B5FD;
|
||||
--gold-dark: #8B5CF6;
|
||||
--paper: #141414;
|
||||
--paper-dark: #0D0D0D;
|
||||
--surface: #2D2D2D;
|
||||
--sage: #4ADE80;
|
||||
--amber: #FBBF24;
|
||||
--warm-gray: #999999;
|
||||
--warm-border: #3A3A3A;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #262626;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #60A5FA;
|
||||
--c-text: #E5E5E5;
|
||||
--c-text-secondary: #999999;
|
||||
--c-text-muted: #666666;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.30);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Misc dark tokens ── */
|
||||
--c-bg-light: #1E1E1E;
|
||||
|
||||
/* ── Sidebar — dark minimal ── */
|
||||
--sidebar-bg: #1A1A1A;
|
||||
--sidebar-color: #E5E5E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: #777777;
|
||||
--sidebar-nav-text-hover: #CCCCCC;
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
|
||||
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: #2A2A2A;
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: #666666;
|
||||
--sidebar-group-label-color: #555555;
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: #999999;
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(167,139,250,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #E5E5E5;
|
||||
|
||||
/* ── Fonts — sans-serif minimal ── */
|
||||
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Tech Dark — 科技深色 (编辑风暗色版)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="tech-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #C8D6E5;
|
||||
--ink-light: #A0B4CC;
|
||||
--ink-dark: #E8EEF4;
|
||||
--vermilion: #F26B6B;
|
||||
--vermilion-light: #F59898;
|
||||
--vermilion-dark: #E55555;
|
||||
--gold: #5B8DEF;
|
||||
--gold-light: #7BA5F5;
|
||||
--gold-dark: #4070D0;
|
||||
--paper: #070D1A;
|
||||
--paper-dark: #050910;
|
||||
--surface: #111B2E;
|
||||
--sage: #4EC9B0;
|
||||
--amber: #E5C07B;
|
||||
--warm-gray: #6B7A8D;
|
||||
--warm-border: #1E2D45;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #131D30;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #5B8DEF;
|
||||
--c-text: #C8D6E5;
|
||||
--c-text-secondary: #6B7A8D;
|
||||
--c-text-muted: #455570;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
|
||||
--shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
|
||||
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
|
||||
--radius: 2px;
|
||||
--radius-sm: 2px;
|
||||
--radius-xs: 2px;
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Misc dark tokens ── */
|
||||
--c-bg-light: #101A28;
|
||||
|
||||
/* ── Sidebar — dark tech ── */
|
||||
--sidebar-bg: #0B1120;
|
||||
--sidebar-color: #C8D6E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: rgba(200,214,229,0.45);
|
||||
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
|
||||
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: rgba(200,214,229,0.06);
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
|
||||
--sidebar-group-label-color: rgba(200,214,229,0.20);
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: rgba(200,214,229,0.65);
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(91,141,239,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #C8D6E5;
|
||||
|
||||
/* ── Fonts — editorial Chinese ── */
|
||||
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dark Mode — Element Plus CSS Variable Overrides
|
||||
Override --el-* variables so ALL components render dark
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
html.dark {
|
||||
--el-color-white: var(--surface);
|
||||
--el-color-black: var(--c-text);
|
||||
--el-color-primary: var(--c-gold);
|
||||
--el-color-primary-light-3: var(--gold-light);
|
||||
--el-color-primary-light-5: var(--gold-light);
|
||||
--el-color-primary-light-7: var(--c-primary-bg);
|
||||
--el-color-primary-light-8: var(--c-primary-bg);
|
||||
--el-color-primary-light-9: var(--paper-dark);
|
||||
--el-color-primary-dark-2: var(--gold-dark);
|
||||
--el-color-success: var(--c-success);
|
||||
--el-color-warning: var(--c-warning);
|
||||
--el-color-danger: var(--c-danger);
|
||||
--el-color-info: var(--c-info);
|
||||
--el-bg-color: var(--paper);
|
||||
--el-bg-color-page: var(--paper);
|
||||
--el-bg-color-overlay: var(--surface);
|
||||
--el-text-color-primary: var(--c-text);
|
||||
--el-text-color-regular: var(--c-text);
|
||||
--el-text-color-secondary: var(--c-text-secondary);
|
||||
--el-text-color-placeholder: var(--c-text-muted);
|
||||
--el-text-color-disabled: var(--c-text-muted);
|
||||
--el-border-color: var(--warm-border);
|
||||
--el-border-color-light: var(--warm-border);
|
||||
--el-border-color-lighter: var(--warm-border);
|
||||
--el-border-color-extra-light: var(--warm-border);
|
||||
--el-border-color-dark: var(--c-text-muted);
|
||||
--el-fill-color: var(--c-primary-bg);
|
||||
--el-fill-color-light: var(--c-primary-bg);
|
||||
--el-fill-color-lighter: var(--paper-dark);
|
||||
--el-fill-color-blank: var(--surface);
|
||||
--el-disabled-bg-color: var(--paper-dark);
|
||||
--el-disabled-text-color: var(--c-text-muted);
|
||||
--el-disabled-border-color: var(--warm-border);
|
||||
--el-mask-color: rgba(0,0,0,0.6);
|
||||
--el-mask-color-extra-light: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
@@ -466,4 +675,211 @@ body.col-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dark Mode — Element Plus Global Overrides
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
html.dark .el-input__wrapper {
|
||||
background: var(--surface) !important;
|
||||
box-shadow: 0 0 0 1px var(--warm-border) inset !important;
|
||||
}
|
||||
html.dark .el-input__inner {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--c-text-muted) inset !important;
|
||||
}
|
||||
html.dark .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px var(--gold) inset !important;
|
||||
}
|
||||
|
||||
/* Select dropdown */
|
||||
html.dark .el-select-dropdown {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item:hover {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item.selected {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
html.dark .el-table {
|
||||
--el-table-bg-color: var(--surface) !important;
|
||||
--el-table-tr-bg-color: var(--surface) !important;
|
||||
--el-table-header-bg-color: var(--c-primary-bg) !important;
|
||||
--el-table-row-hover-bg-color: var(--c-primary-bg) !important;
|
||||
--el-table-border-color: var(--warm-border) !important;
|
||||
--el-table-text-color: var(--c-text) !important;
|
||||
--el-table-header-text-color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-table th.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-table td.el-table__cell {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
html.dark .el-table__body tr:hover > td.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
html.dark .el-pagination button,
|
||||
html.dark .el-pager li {
|
||||
background: var(--surface) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-pager li.is-active {
|
||||
background: var(--ink) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
html.dark .el-pagination button:hover,
|
||||
html.dark .el-pager li:hover {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Button — default / text variant */
|
||||
html.dark .el-button--default {
|
||||
--el-button-bg-color: var(--surface);
|
||||
--el-button-border-color: var(--warm-border);
|
||||
--el-button-text-color: var(--c-text);
|
||||
--el-button-hover-bg-color: var(--c-primary-bg);
|
||||
--el-button-hover-border-color: var(--c-text-muted);
|
||||
--el-button-hover-text-color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
html.dark .el-dialog {
|
||||
--el-dialog-bg-color: var(--surface);
|
||||
}
|
||||
html.dark .el-dialog__title {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-dialog__header {
|
||||
border-bottom: 1px solid var(--warm-border);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
html.dark .el-tabs__item {
|
||||
color: var(--c-text-secondary) !important;
|
||||
}
|
||||
html.dark .el-tabs__item.is-active {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-tabs__header {
|
||||
border-bottom-color: var(--warm-border);
|
||||
}
|
||||
|
||||
/* Tag */
|
||||
html.dark .el-tag {
|
||||
--el-tag-bg-color: var(--c-primary-bg);
|
||||
--el-tag-border-color: var(--warm-border);
|
||||
--el-tag-text-color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Form label */
|
||||
html.dark .el-form-item__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
html.dark .el-progress-bar__outer {
|
||||
background: var(--paper-dark) !important;
|
||||
}
|
||||
|
||||
/* Date picker panel */
|
||||
html.dark .el-picker-panel {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-date-picker__header-label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-date-table td {
|
||||
color: var(--c-text);
|
||||
}
|
||||
html.dark .el-date-table td.current:not(.disabled) .el-date-table-cell__text {
|
||||
background: var(--gold) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
html.dark .el-date-table td.today .el-date-table-cell__text {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Time picker panel */
|
||||
html.dark .el-time-panel {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-time-spinner__item {
|
||||
color: var(--c-text-muted) !important;
|
||||
}
|
||||
html.dark .el-time-spinner__item.is-active {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Popover / tooltip */
|
||||
html.dark .el-popover,
|
||||
html.dark .el-tooltip__popper {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Message box confirm */
|
||||
html.dark .el-message-box {
|
||||
--el-messagebox-bg-color: var(--surface);
|
||||
--el-messagebox-title-color: var(--c-text);
|
||||
--el-messagebox-content-color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
html.dark .el-empty__description p {
|
||||
color: var(--c-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Radio / Checkbox label */
|
||||
html.dark .el-radio__label,
|
||||
html.dark .el-checkbox__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Cascader / Tree select */
|
||||
html.dark .el-cascader-node__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-cascader-menu {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
html.dark .el-alert {
|
||||
--el-alert-bg-color: var(--c-primary-bg);
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
html.dark .el-drawer {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
html.dark .el-divider__text {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Loading mask */
|
||||
html.dark .el-loading-mask {
|
||||
background-color: rgba(0,0,0,0.5) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,4 +13,17 @@ export const miniBusinessApi = {
|
||||
delete(id: string) {
|
||||
return api.delete(`/mini-business/${id}`)
|
||||
},
|
||||
// Follow-up logs
|
||||
getLogs(businessId: string) {
|
||||
return api.get(`/mini-business/${businessId}/logs`)
|
||||
},
|
||||
createLog(businessId: string, data: any) {
|
||||
return api.post(`/mini-business/${businessId}/logs`, data)
|
||||
},
|
||||
updateLog(logId: string, data: any) {
|
||||
return api.put(`/mini-business/logs/${logId}`, data)
|
||||
},
|
||||
deleteLog(logId: string) {
|
||||
return api.delete(`/mini-business/logs/${logId}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore, themeLabels } from '@/stores/theme'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -44,13 +44,15 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
label: '管理',
|
||||
items: [
|
||||
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
|
||||
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
|
||||
],
|
||||
})
|
||||
if (auth.isDirector) {
|
||||
groups[groups.length - 1].items.splice(1, 0,
|
||||
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
|
||||
)
|
||||
groups[groups.length - 1].items.push(
|
||||
{ path: '/audit-log', label: '操作日志', icon: '<polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>' },
|
||||
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
|
||||
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
|
||||
)
|
||||
}
|
||||
return groups
|
||||
@@ -63,8 +65,7 @@ const roleLabel = computed(() => {
|
||||
})
|
||||
|
||||
function cycleTheme() {
|
||||
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
|
||||
themeStore.setTheme(next)
|
||||
themeStore.toggleMode()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -109,11 +110,11 @@ function cycleTheme() {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="topbar-actions">
|
||||
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
|
||||
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + (themeStore.mode === 'light' ? '深色模式' : '亮色模式')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle v-if="themeStore.currentTheme === 'editorial'" cx="12" cy="12" r="5"></circle>
|
||||
<path v-if="themeStore.currentTheme === 'editorial'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
<path v-if="themeStore.currentTheme === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
<path v-if="themeStore.mode === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
<circle v-if="themeStore.mode === 'dark'" cx="12" cy="12" r="5"></circle>
|
||||
<path v-if="themeStore.mode === 'dark'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="topbar-btn" @click="router.push('/m')" title="移动端">
|
||||
|
||||
@@ -32,10 +32,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userId.value = ''
|
||||
name.value = ''
|
||||
role.value = ''
|
||||
// Preserve theme preference across logout
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
// Preserve theme preferences across logout
|
||||
const savedMode = localStorage.getItem('theme-mode')
|
||||
const savedLight = localStorage.getItem('theme-light')
|
||||
const savedDark = localStorage.getItem('theme-dark')
|
||||
localStorage.clear()
|
||||
if (savedTheme) localStorage.setItem('theme', savedTheme)
|
||||
if (savedMode) localStorage.setItem('theme-mode', savedMode)
|
||||
if (savedLight) localStorage.setItem('theme-light', savedLight)
|
||||
if (savedDark) localStorage.setItem('theme-dark', savedDark)
|
||||
}
|
||||
|
||||
async function casdoorLogin(code: string, state: string) {
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type Theme = 'editorial' | 'light'
|
||||
export type Theme = 'editorial' | 'light' | 'minimal-dark' | 'tech-dark'
|
||||
type Mode = 'light' | 'dark'
|
||||
|
||||
export const themeLabels: Record<Theme, string> = {
|
||||
editorial: '编辑风',
|
||||
light: '极简亮色',
|
||||
'minimal-dark': '极简深色',
|
||||
'tech-dark': '科技深色',
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const currentTheme = ref<Theme>(
|
||||
(localStorage.getItem('theme') as Theme) || 'editorial'
|
||||
)
|
||||
export const lightThemes: Theme[] = ['editorial', 'light']
|
||||
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<Mode>((localStorage.getItem('theme-mode') as Mode) || 'light')
|
||||
const lightTheme = ref<Theme>((localStorage.getItem('theme-light') as Theme) || 'editorial')
|
||||
const darkTheme = ref<Theme>((localStorage.getItem('theme-dark') as Theme) || 'tech-dark')
|
||||
|
||||
const effectiveTheme = computed<Theme>(() => {
|
||||
return mode.value === 'light' ? lightTheme.value : darkTheme.value
|
||||
})
|
||||
|
||||
function applyTheme(theme: Theme, isDark: boolean) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
async function setTheme(theme: Theme) {
|
||||
currentTheme.value = theme
|
||||
localStorage.setItem('theme', theme)
|
||||
applyTheme(theme)
|
||||
// Sync to backend (fire-and-forget)
|
||||
try {
|
||||
const { default: api } = await import('@/api/index')
|
||||
await api.put('/users/me/theme', { theme })
|
||||
} catch (_) { /* non-critical */ }
|
||||
function apply() {
|
||||
applyTheme(effectiveTheme.value, mode.value === 'dark')
|
||||
}
|
||||
|
||||
function setLightTheme(theme: Theme) {
|
||||
lightTheme.value = theme
|
||||
localStorage.setItem('theme-light', theme)
|
||||
if (mode.value === 'light') apply()
|
||||
}
|
||||
|
||||
function setDarkTheme(theme: Theme) {
|
||||
darkTheme.value = theme
|
||||
localStorage.setItem('theme-dark', theme)
|
||||
if (mode.value === 'dark') apply()
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme-mode', mode.value)
|
||||
apply()
|
||||
}
|
||||
|
||||
// Apply on init
|
||||
applyTheme(currentTheme.value)
|
||||
apply()
|
||||
|
||||
return { currentTheme, setTheme }
|
||||
return { mode, lightTheme, darkTheme, effectiveTheme, setLightTheme, setDarkTheme, toggleMode }
|
||||
})
|
||||
|
||||
@@ -5,17 +5,124 @@ import { useThemeStore } from '@/stores/theme'
|
||||
import { customersApi } from '@/api/customers'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getManagerColor as mgrColorFn, getMgrTextColor, loadManagerColors } from '@/utils/managerColor'
|
||||
import { compressImage } from '@/utils/image'
|
||||
import api from '@/api/index'
|
||||
import axios from 'axios'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const filterIndustry = ref('')
|
||||
const filterService = ref('')
|
||||
const filterManagerId = ref('')
|
||||
const customerType = ref('unit')
|
||||
const customers = ref<any[]>([])
|
||||
|
||||
// Individual view tab data
|
||||
const indivTab = ref('visits')
|
||||
const indivVisits = ref<any[]>([])
|
||||
const indivPlans = ref<any[]>([])
|
||||
const indivMini = ref<any[]>([])
|
||||
const indivKeyVisits = ref<any[]>([])
|
||||
const indivLoading = ref(false)
|
||||
|
||||
// Individual record edit
|
||||
const indivEditVisible = ref(false)
|
||||
const indivEditType = ref('')
|
||||
const indivEditForm = ref<any>({})
|
||||
const indivEditSaving = ref(false)
|
||||
const indivPhotoUrls = ref<Record<string, string>>({})
|
||||
|
||||
async function openIndivEdit(type: string, row: any) {
|
||||
indivEditType.value = type
|
||||
indivEditForm.value = { ...row }
|
||||
indivEditVisible.value = true
|
||||
// Load photo URLs for visit editing
|
||||
if (type === 'visit' && row.photos?.length) {
|
||||
for (const key of row.photos) {
|
||||
if (!indivPhotoUrls.value[key]) {
|
||||
try {
|
||||
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
|
||||
indivPhotoUrls.value[key] = urlRes.data.download_url
|
||||
} catch (_) { indivPhotoUrls.value[key] = '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIndivPhotoUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target.files?.length) return
|
||||
const form = indivEditForm.value
|
||||
if (!form.photos) form.photos = []
|
||||
if (form.photos.length >= 9) { ElMessage.warning('最多9张照片'); return }
|
||||
for (const file of Array.from(target.files)) {
|
||||
if (form.photos.length >= 9) break
|
||||
try {
|
||||
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
|
||||
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: compressed.name, content_type: compressed.type || 'image/jpeg' } })
|
||||
await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } })
|
||||
form.photos.push(presignRes.data.object_key)
|
||||
indivPhotoUrls.value[presignRes.data.object_key] = URL.createObjectURL(compressed)
|
||||
} catch (_) { ElMessage.error('上传失败') }
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function removeIndivPhoto(idx: number) {
|
||||
const form = indivEditForm.value
|
||||
const key = form.photos[idx]
|
||||
delete indivPhotoUrls.value[key]
|
||||
form.photos.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function handleIndivEditSave() {
|
||||
indivEditSaving.value = true
|
||||
try {
|
||||
const type = indivEditType.value
|
||||
const f = indivEditForm.value
|
||||
if (type === 'visit') {
|
||||
await api.put(`/visits/${f.id}`, {
|
||||
communication_content: f.communication_content,
|
||||
customer_demand: f.customer_demand || '',
|
||||
visitor_name: f.visitor_name || '',
|
||||
visitor_phone: f.visitor_phone || '',
|
||||
visit_method: f.visit_method,
|
||||
time_range: f.time_range || '',
|
||||
visit_date: f.visit_date,
|
||||
manager_id: f.manager_id,
|
||||
photos: f.photos || [],
|
||||
})
|
||||
} else if (type === 'plan') {
|
||||
await api.put(`/work-plans/${f.id}`, { plan_content: f.plan_content, status: f.status, plan_date: f.plan_date, manager_id: f.manager_id })
|
||||
} else if (type === 'mini') {
|
||||
await api.put(`/mini-business/${f.id}`, { product_type: f.product_type, amount: f.amount, follow_up_detail: f.follow_up_detail, status: f.status, expected_revenue_date: f.expected_revenue_date, manager_id: f.manager_id })
|
||||
} else if (type === 'key') {
|
||||
await api.put(`/key-visits/${f.id}`, { description: f.description, urgency_level: f.urgency_level, progress_status: f.progress_status, planned_date: f.planned_date, planned_visitor: f.planned_visitor, visit_target: f.visit_target, manager_id: f.manager_id })
|
||||
}
|
||||
ElMessage.success('已更新')
|
||||
indivEditVisible.value = false
|
||||
loadIndividualData()
|
||||
} catch (e: any) { ElMessage.error('保存失败') }
|
||||
finally { indivEditSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleIndivEditDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
const type = indivEditType.value
|
||||
const id = indivEditForm.value.id
|
||||
if (type === 'visit') await api.delete(`/visits/${id}`)
|
||||
else if (type === 'plan') await api.delete(`/work-plans/${id}`)
|
||||
else if (type === 'mini') await api.delete(`/mini-business/${id}`)
|
||||
else if (type === 'key') await api.delete(`/key-visits/${id}`)
|
||||
ElMessage.success('已删除')
|
||||
indivEditVisible.value = false
|
||||
loadIndividualData()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
@@ -36,6 +143,12 @@ const feeCustomUnit = ref('')
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailCustomer = ref<any>(null)
|
||||
const detailActiveTab = ref('visits')
|
||||
const detailVisits = ref<any[]>([])
|
||||
const detailPlans = ref<any[]>([])
|
||||
const detailMini = ref<any[]>([])
|
||||
const detailKeyVisits = ref<any[]>([])
|
||||
const detailLoading = ref(false)
|
||||
|
||||
const managers = ref<any[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
@@ -62,6 +175,7 @@ async function loadCustomers() {
|
||||
if (filterIndustry.value) params.industry = filterIndustry.value
|
||||
if (filterService.value) params.service = filterService.value
|
||||
if (filterManagerId.value) params.manager_id = filterManagerId.value
|
||||
if (customerType.value) params.customer_type = customerType.value
|
||||
const res = await customersApi.list(params)
|
||||
customers.value = res.data.items
|
||||
total.value = res.data.total
|
||||
@@ -73,6 +187,32 @@ function onPageChange(page: number) { currentPage.value = page; loadCustomers()
|
||||
function onPageSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; loadCustomers() }
|
||||
function onFilterChange() { currentPage.value = 1; loadCustomers() }
|
||||
|
||||
function switchCustomerType(type: string) {
|
||||
customerType.value = type
|
||||
if (type === 'unit') {
|
||||
onFilterChange()
|
||||
} else {
|
||||
loadIndividualData()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIndividualData() {
|
||||
indivLoading.value = true
|
||||
try {
|
||||
const [visits, plans, mini, keyVisits] = await Promise.all([
|
||||
api.get('/visits/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/work-plans/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/mini-business/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/key-visits/', { params: { customer_type: 'individual' } }),
|
||||
])
|
||||
indivVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
|
||||
indivPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
|
||||
indivMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
|
||||
indivKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
|
||||
} catch (_) {}
|
||||
finally { indivLoading.value = false }
|
||||
}
|
||||
|
||||
function buildMonthlyFee(): string {
|
||||
const amt = form.value.fee_amount.trim()
|
||||
if (!amt) return ''
|
||||
@@ -205,7 +345,30 @@ function mgrTextColor(name: string) {
|
||||
}
|
||||
|
||||
async function openDetail(customer: any) {
|
||||
try { const res = await customersApi.get(customer.id); detailCustomer.value = res.data; detailVisible.value = true } catch (_) {}
|
||||
try {
|
||||
const res = await customersApi.get(customer.id)
|
||||
detailCustomer.value = res.data
|
||||
detailActiveTab.value = 'visits'
|
||||
detailVisible.value = true
|
||||
loadDetailData(customer.id)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadDetailData(customerId: string) {
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [visits, plans, mini, keyVisits] = await Promise.all([
|
||||
api.get('/visits/', { params: { customer_id: customerId } }),
|
||||
api.get('/work-plans/', { params: { customer_id: customerId } }),
|
||||
api.get('/mini-business/', { params: { customer_id: customerId } }),
|
||||
api.get('/key-visits/', { params: { customer_id: customerId } }),
|
||||
])
|
||||
detailVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
|
||||
detailPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
|
||||
detailMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
|
||||
detailKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
|
||||
} catch (_) {}
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete(customer: any) {
|
||||
@@ -294,12 +457,72 @@ async function handleImport() {
|
||||
</svg>
|
||||
导出
|
||||
</el-button>
|
||||
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
|
||||
<el-button v-if="auth.isDirector" type="primary" @click="openCreate" :disabled="customerType === 'individual'">+ 新建客户</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
|
||||
<!-- Type Toggle -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div class="type-tabs">
|
||||
<button :class="['type-tab', { 'type-tab--active': customerType === 'unit' }]" @click="switchCustomerType('unit')">单位客户</button>
|
||||
<button :class="['type-tab', { 'type-tab--active': customerType === 'individual' }]" @click="switchCustomerType('individual')">个人用户</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual View: 4 Tabs -->
|
||||
<div v-if="customerType === 'individual'" v-loading="indivLoading">
|
||||
<el-tabs v-model="indivTab">
|
||||
<el-tab-pane label="拜访记录" name="visits">
|
||||
<el-table :data="indivVisits" size="small" max-height="400" v-if="indivVisits.length">
|
||||
<el-table-column prop="visit_date" label="日期" width="110" />
|
||||
<el-table-column prop="visit_method" label="方式" width="80" />
|
||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="visitor_name" label="拜访人" width="80" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="100" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('visit', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无拜访记录</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="工作计划" name="plans">
|
||||
<el-table :data="indivPlans" size="small" max-height="400" v-if="indivPlans.length">
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('plan', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无工作计划</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="商机跟单" name="mini">
|
||||
<el-table :data="indivMini" size="small" max-height="400" v-if="indivMini.length">
|
||||
<el-table-column prop="product_type" label="产品类型" width="110" />
|
||||
<el-table-column prop="amount" label="金额" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('mini', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无商机记录</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="要客拜访" name="keyVisits">
|
||||
<el-table :data="indivKeyVisits" size="small" max-height="400" v-if="indivKeyVisits.length">
|
||||
<el-table-column prop="planned_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="description" label="内容" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="urgency_level" label="紧急度" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('key', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂不要客记录</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- Unit View: Search + Table -->
|
||||
<template v-if="customerType === 'unit'">
|
||||
|
||||
<!-- Search & Filter -->
|
||||
<el-card style="margin-bottom: 16px;">
|
||||
<el-row :gutter="12" align="middle">
|
||||
@@ -403,25 +626,76 @@ async function handleImport() {
|
||||
</el-dialog>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" title="客户详情" width="500px">
|
||||
<template v-if="detailCustomer">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="单位名称">{{ detailCustomer.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="行业">{{ detailCustomer.industry || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址">{{ detailCustomer.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在用业务">{{ detailCustomer.in_use_services || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收支费用">{{ detailCustomer.monthly_fee || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ detailCustomer.remarks || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户经理">{{ detailCustomer.primary_manager_name || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4 style="margin-top:16px; font-family: var(--font-heading); color: var(--ink)">联系人</h4>
|
||||
<div v-if="detailCustomer.contacts?.length">
|
||||
<el-tag v-for="c in detailCustomer.contacts" :key="c.id" style="margin:4px">
|
||||
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
|
||||
</el-tag>
|
||||
<el-dialog v-model="detailVisible" :title="detailCustomer?.name || '客户详情'" width="800px" v-if="detailCustomer">
|
||||
<!-- Basic Info Card -->
|
||||
<div class="cust-info-card">
|
||||
<div class="cust-info-name">{{ detailCustomer.name }}</div>
|
||||
<div class="cust-info-bar">
|
||||
<span class="cust-info-item">行业:{{ detailCustomer.industry || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">地址:{{ detailCustomer.address || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">费用:{{ detailCustomer.monthly_fee || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">客户经理:{{ detailCustomer.primary_manager_name || '-' }}</span>
|
||||
</div>
|
||||
<div v-else style="color: var(--c-text-muted); text-align:center; padding:12px">暂无联系人</div>
|
||||
</template>
|
||||
<div v-if="detailCustomer.in_use_services" class="cust-info-bar">
|
||||
<span class="cust-info-item">在用业务:{{ detailCustomer.in_use_services }}</span>
|
||||
</div>
|
||||
<div v-if="detailCustomer.contacts?.length" class="cust-info-bar">
|
||||
<span class="cust-info-item">联系人:{{ detailCustomer.contacts.map((c:any) => c.name + (c.phone ? ' '+c.phone : '')).join('、') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="detailActiveTab" style="margin-top:16px">
|
||||
<el-tab-pane label="拜访记录" name="visits">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailVisits" size="small" max-height="300" v-if="detailVisits.length">
|
||||
<el-table-column prop="visit_date" label="日期" width="110" />
|
||||
<el-table-column prop="visit_method" label="方式" width="80" />
|
||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无拜访记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="工作计划" name="plans">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailPlans" size="small" max-height="300" v-if="detailPlans.length">
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无工作计划</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="商机跟单" name="mini">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailMini" size="small" max-height="300" v-if="detailMini.length">
|
||||
<el-table-column prop="product_type" label="产品类型" width="110" />
|
||||
<el-table-column prop="amount" label="金额" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无商机记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="要客拜访" name="keyVisits">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailKeyVisits" size="small" max-height="300" v-if="detailKeyVisits.length">
|
||||
<el-table-column prop="planned_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="description" label="内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="urgency_level" label="紧急度" width="80" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂不要客记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!auth.isLeader" type="primary" @click="detailVisible=false; openEdit(detailCustomer)">编辑</el-button>
|
||||
<el-button v-if="auth.isDirector" type="danger" @click="detailVisible=false; handleDelete(detailCustomer)">删除</el-button>
|
||||
@@ -454,6 +728,92 @@ async function handleImport() {
|
||||
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<!-- Individual Record Edit Dialog -->
|
||||
<el-dialog v-model="indivEditVisible" :title="'编辑' + ({visit:'拜访',plan:'计划',mini:'商机',key:'要客'}[indivEditType]||'')" width="550px">
|
||||
<el-form label-position="top" v-if="indivEditForm">
|
||||
<template v-if="indivEditType === 'visit'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期"><el-date-picker v-model="indivEditForm.visit_date" type="date" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="拜访方式">
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="indivEditForm.visit_method === m ? 'primary' : ''" @click="indivEditForm.visit_method = m">{{ m }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围"><el-input v-model="indivEditForm.time_range" placeholder="如: 10:00-11:30" /></el-form-item>
|
||||
<el-form-item label="拜访人姓名"><el-input v-model="indivEditForm.visitor_name" /></el-form-item>
|
||||
<el-form-item label="拜访人电话"><el-input v-model="indivEditForm.visitor_phone" /></el-form-item>
|
||||
<el-form-item label="相关人员"><el-input v-model="indivEditForm.companion_names_str" placeholder="多个姓名用、分隔" /></el-form-item>
|
||||
<el-form-item label="沟通内容"><el-input v-model="indivEditForm.communication_content" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="客户需求"><el-input v-model="indivEditForm.customer_demand" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item v-if="indivEditForm.photos?.length" label="照片">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<div v-for="(key, idx) in indivEditForm.photos" :key="key" style="position:relative">
|
||||
<img :src="indivPhotoUrls[key]" style="width:80px;height:80px;object-fit:cover;border:1px solid var(--warm-border)" v-if="indivPhotoUrls[key]" />
|
||||
<span v-else style="width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--paper);border:1px solid var(--warm-border);font-size:12px;color:var(--warm-gray)">加载中</span>
|
||||
<button type="button" style="position:absolute;top:-6px;right:-6px;background:var(--vermilion);color:#fff;border:none;width:18px;height:18px;cursor:pointer;font-size:12px;line-height:1" @click="removeIndivPhoto(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加照片">
|
||||
<label style="cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:1px dashed var(--warm-border);color:var(--warm-gray);font-size:13px">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
|
||||
上传照片(最多9张)
|
||||
<input type="file" accept="image/*" multiple style="display:none" @change="handleIndivPhotoUpload" />
|
||||
</label>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'plan'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划日期"><el-date-picker v-model="indivEditForm.plan_date" type="date" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="工作计划"><el-input v-model="indivEditForm.plan_content" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['计划中','已完成','已取消']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'mini'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类型"><el-input v-model="indivEditForm.product_type" /></el-form-item>
|
||||
<el-form-item label="金额"><el-input v-model="indivEditForm.amount" /></el-form-item>
|
||||
<el-form-item label="跟进内容"><el-input v-model="indivEditForm.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['跟进中','已签约','已流失']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
<el-form-item label="预计列收时间"><el-date-picker v-model="indivEditForm.expected_revenue_date" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" /></el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'key'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="紧急重要度">
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="indivEditForm.urgency_level === u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="indivEditForm.urgency_level = u">{{ u }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容描述"><el-input v-model="indivEditForm.description" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="进展状态"><el-select v-model="indivEditForm.progress_status" style="width:100%"><el-option v-for="s in ['未开始','进行中','已完成']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
<el-form-item label="计划拜访时间"><el-date-picker v-model="indivEditForm.planned_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="计划拜访人"><el-input v-model="indivEditForm.planned_visitor" placeholder="多个用、分隔" /></el-form-item>
|
||||
<el-form-item label="拜访对象"><el-input v-model="indivEditForm.visit_target" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="danger" @click="handleIndivEditDelete" style="float:left">删除</el-button>
|
||||
<el-button @click="indivEditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="indivEditSaving" @click="handleIndivEditSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Merge confirmation dialog -->
|
||||
<el-dialog v-model="mergeDialogVisible" title="合并客户" width="520px" :close-on-click-modal="false">
|
||||
@@ -499,4 +859,16 @@ async function handleImport() {
|
||||
}
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 8px; }
|
||||
.header-btns { display: flex; gap: 8px; }
|
||||
.cust-info-bar { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--ink); padding: 2px 0; }
|
||||
.cust-info-item { white-space: nowrap; }
|
||||
.cust-info-sep { color: var(--warm-border); margin: 0 6px; }
|
||||
.empty-tab { text-align: center; color: var(--c-text-muted); padding: 24px 0 12px; font-size: 13px; }
|
||||
.type-tabs { display: inline-flex; border: 1px solid var(--warm-border); }
|
||||
.type-tab { padding: 8px 22px; border: none; background: var(--surface); font-family: var(--font-heading); font-size: 14px; color: var(--warm-gray); cursor: pointer; transition: all 0.2s; letter-spacing: 0.04em; }
|
||||
.type-tab:not(:last-child) { border-right: 1px solid var(--warm-border); }
|
||||
.type-tab:hover { color: var(--ink); }
|
||||
.type-tab--active { background: var(--ink); color: #fff; }
|
||||
.type-tab--active:hover { color: #fff; }
|
||||
.cust-info-card { background: var(--c-bg-light, #faf9f6); border: 1px solid var(--warm-border); border-radius: 6px; padding: 14px 18px; margin-bottom: 4px; }
|
||||
.cust-info-name { font-family: var(--font-heading); font-size: 16px; color: var(--ink); letter-spacing: 0.04em; margin-bottom: 6px; }
|
||||
</style>
|
||||
|
||||
@@ -427,4 +427,17 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
|
||||
.leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); }
|
||||
.rest-text { font-size: 13px; color: #9CA3AF; font-family: var(--font-body); }
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
|
||||
|
||||
/* ═══ Dark Mode — Dashboard ═══ */
|
||||
html.dark .stat-glyph--ink,
|
||||
html.dark .stat-glyph--sage,
|
||||
html.dark .stat-glyph--gold,
|
||||
html.dark .stat-glyph--vermilion {
|
||||
background: transparent;
|
||||
}
|
||||
html.dark .progress-row--missing { background: rgba(248,113,113,0.06); }
|
||||
html.dark .progress-row--catching { background: rgba(251,191,36,0.05); }
|
||||
html.dark .progress-row--full { background: rgba(74,222,128,0.05); }
|
||||
html.dark .progress-row--on_leave { background: rgba(96,165,250,0.05); }
|
||||
html.dark .progress-row--rest_day { background: rgba(156,163,175,0.03); }
|
||||
</style>
|
||||
|
||||
@@ -12,9 +12,10 @@ import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const keyVisits = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
const allUsers = ref<any[]>([])
|
||||
@@ -29,6 +30,8 @@ const keyStatuses = ['未开始', '进行中', '已完成']
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const searchText = ref('')
|
||||
function onSearch() { loadItems() }
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -79,6 +82,9 @@ const managerSummary = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadItems(), loadCustomers(), loadUsers(), loadManagerColors()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -88,6 +94,8 @@ async function loadUsers() {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 500 }
|
||||
@@ -97,10 +105,34 @@ async function loadCustomers(q?: string) {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleCustomerSearch(query: string) {
|
||||
customerSearch.value = query
|
||||
if (query) { await loadCustomers(query) }
|
||||
else { await loadCustomers() }
|
||||
}
|
||||
|
||||
async function handleQuickCreate() {
|
||||
const name = customerSearch.value.trim()
|
||||
if (!name) { ElMessage.warning('请输入单位名称'); return }
|
||||
try {
|
||||
const res = await api.post('/customers/quick-create', null, { params: { name } })
|
||||
const newCust = res.data
|
||||
customers.value.unshift(newCust)
|
||||
form.value.customer_id = newCust.id
|
||||
customerSearch.value = ''
|
||||
ElMessage.success(`已创建客户:${name}`)
|
||||
await loadCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/key-visits/')
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/key-visits/', { params })
|
||||
keyVisits.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -108,7 +140,7 @@ async function loadItems() {
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'
|
||||
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
|
||||
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
plannedVisitors.value = []
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -128,6 +160,7 @@ function onVisitorsChange(val: string[]) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
await api.post('/key-visits/', form.value)
|
||||
@@ -198,6 +231,9 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
@@ -269,9 +305,20 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="紧急重要度">
|
||||
@@ -329,4 +376,8 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
|
||||
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@ const leaveTypeColors: Record<string, string> = {
|
||||
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
@@ -138,7 +138,9 @@ async function handleSave() {
|
||||
dialogVisible.value = false
|
||||
await loadLeaves()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message))
|
||||
const detail = e.response?.data?.detail
|
||||
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
|
||||
ElMessage.error('保存失败: ' + msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,12 +278,14 @@ function onStatusChange(val: string) {
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
style="width:100%; margin-bottom: 8px"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="form.end_date"
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
style="width:100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因(选填)">
|
||||
@@ -329,4 +333,5 @@ function onStatusChange(val: string) {
|
||||
border-left: 3px solid #5B7FA5 !important;
|
||||
background: rgba(91, 127, 165, 0.04) !important;
|
||||
}
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -141,6 +141,11 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
|
||||
<span class="manager-expand">{{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }}</span>
|
||||
<span class="manager-name" @click.stop="goManagerReport(m.manager_id)">{{ m.manager_name }}</span>
|
||||
<span class="manager-count">{{ m.total_customers }} 个客户</span>
|
||||
<span class="manager-metrics">
|
||||
已拜访<strong>{{ m.visited_this_month }}</strong> ·
|
||||
未拜访<strong>{{ m.not_visited_2months }}</strong> ·
|
||||
完成率<strong :style="{ color: coverageColor(m.coverage_rate) }">{{ (m.coverage_rate * 100).toFixed(0) }}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="manager-lights">
|
||||
<span class="light-dot light-dot--green">{{ m.visited_this_month }}</span>
|
||||
@@ -304,6 +309,8 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
|
||||
.manager-name { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; cursor: pointer; }
|
||||
.manager-name:hover { color: var(--gold); }
|
||||
.manager-count { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
|
||||
.manager-metrics { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); white-space: nowrap; }
|
||||
.manager-metrics strong { font-weight: 600; color: var(--ink); margin: 0 1px; }
|
||||
.manager-lights { display: flex; gap: 6px; }
|
||||
.light-dot { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 26px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; font-weight: 600; cursor: help; }
|
||||
.light-dot--green { background: #EDF2EC; color: var(--sage); }
|
||||
@@ -382,4 +389,17 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
|
||||
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
|
||||
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
|
||||
.plan-form-row { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* ═══ Dark Mode — light card text stays dark ═══ */
|
||||
html.dark .customer-card .card-name { color: #1A1A1A; }
|
||||
html.dark .customer-card .card-industry,
|
||||
html.dark .customer-card .card-services,
|
||||
html.dark .customer-card .card-last-visit { color: #555; }
|
||||
html.dark .customer-card .card-method { color: #555; border-color: #999; }
|
||||
html.dark .customer-card .card-last-visit--never { color: #B8472E; }
|
||||
html.dark .plan-badge { background: #2D2D2D; color: var(--sage); }
|
||||
html.dark .plan-badge.plan-overdue { background: #3D2020; }
|
||||
html.dark .light-dot--green { background: #1E2F1E; }
|
||||
html.dark .light-dot--yellow { background: #2F2818; }
|
||||
html.dark .light-dot--red { background: #2F1E1E; }
|
||||
</style>
|
||||
|
||||
@@ -13,12 +13,29 @@ const auth = useAuthStore()
|
||||
|
||||
const activeTab = ref('visits')
|
||||
const loading = ref(false)
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
// Date range filter
|
||||
const dateRange = ref<any>(null)
|
||||
const searchText = ref('')
|
||||
const pageSize = ref(25)
|
||||
|
||||
const visits = ref<any[]>([])
|
||||
const workPlans = ref<any[]>([])
|
||||
const miniBusiness = ref<any[]>([])
|
||||
const dailyNotes = ref<any[]>([])
|
||||
const keyVisits = ref<any[]>([])
|
||||
|
||||
// Pagination per tab
|
||||
const page = ref<Record<string, number>>({ visits: 1, notes: 1, plans: 1, mini: 1, key: 1 })
|
||||
const total = ref<Record<string, number>>({ visits: 0, notes: 0, plans: 0, mini: 0, key: 0 })
|
||||
|
||||
function getDateRange() {
|
||||
if (dateRange.value && Array.isArray(dateRange.value) && dateRange.value.length === 2) {
|
||||
return { from: dateRange.value[0], to: dateRange.value[1] }
|
||||
}
|
||||
return { from: '', to: '' }
|
||||
}
|
||||
const customers = ref<any[]>([])
|
||||
const allUsers = ref<any[]>([])
|
||||
const plannedVisitors = ref<string[]>([])
|
||||
@@ -43,6 +60,9 @@ const keyStatuses = ['未开始', '进行中', '已完成']
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadAll(), loadCustomers(), loadUsers()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -57,6 +77,8 @@ function typeLabel(t: string) {
|
||||
return labels[t] || ''
|
||||
}
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
@@ -66,18 +88,61 @@ async function loadCustomers(q?: string) {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleCustomerSearch(query: string) {
|
||||
customerSearch.value = query
|
||||
if (query) { await loadCustomers(query) }
|
||||
else { await loadCustomers() }
|
||||
}
|
||||
|
||||
async function handleQuickCreate() {
|
||||
const name = customerSearch.value.trim()
|
||||
if (!name) { ElMessage.warning('请输入单位名称'); return }
|
||||
try {
|
||||
const res = await api.post('/customers/quick-create', null, { params: { name } })
|
||||
const newCust = res.data
|
||||
customers.value.unshift(newCust)
|
||||
form.value.customer_id = newCust.id
|
||||
customerSearch.value = ''
|
||||
ElMessage.success(`已创建客户:${name}`)
|
||||
await loadCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
function buildParams(tabKey: string) {
|
||||
const { from, to } = getDateRange()
|
||||
const params: any = { page: page.value[tabKey] || 1, page_size: pageSize.value }
|
||||
if (from) { params.date_from = from; params.date_to = to }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [v, w, m, d, k] = await Promise.all([
|
||||
api.get('/visits/'), api.get('/work-plans/'),
|
||||
api.get('/mini-business/'), api.get('/daily-notes/'),
|
||||
api.get('/key-visits/'),
|
||||
const [v, d] = await Promise.all([
|
||||
api.get('/visits/', { params: buildParams('visits') }),
|
||||
api.get('/daily-notes/', { params: buildParams('notes') }),
|
||||
])
|
||||
visits.value = v.data; workPlans.value = w.data
|
||||
miniBusiness.value = m.data; dailyNotes.value = d.data
|
||||
keyVisits.value = k.data
|
||||
// Load photo previews
|
||||
visits.value = Array.isArray(v.data) ? v.data : (v.data.items || [])
|
||||
dailyNotes.value = Array.isArray(d.data) ? d.data : (d.data.items || [])
|
||||
total.value.visits = v.data.total || visits.value.length
|
||||
total.value.notes = d.data.total || dailyNotes.value.length
|
||||
|
||||
const [w, m, k] = await Promise.all([
|
||||
api.get('/work-plans/', { params: buildParams('plans') }),
|
||||
api.get('/mini-business/', { params: buildParams('mini') }),
|
||||
api.get('/key-visits/', { params: buildParams('key') }),
|
||||
])
|
||||
workPlans.value = Array.isArray(w.data) ? w.data : (w.data.items || [])
|
||||
miniBusiness.value = Array.isArray(m.data) ? m.data : (m.data.items || [])
|
||||
keyVisits.value = Array.isArray(k.data) ? k.data : (k.data.items || [])
|
||||
total.value.plans = w.data.total || workPlans.value.length
|
||||
total.value.mini = m.data.total || miniBusiness.value.length
|
||||
total.value.key = k.data.total || keyVisits.value.length
|
||||
|
||||
// Load photo previews for visits
|
||||
for (const visit of visits.value) {
|
||||
if (visit.photos?.length) {
|
||||
for (const key of visit.photos) {
|
||||
@@ -94,14 +159,19 @@ async function loadAll() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onTabChange(tab: string) { activeTab.value = tab; loadAll() }
|
||||
function onFilterChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); loadAll() }
|
||||
function onPageChange(tabKey: string, p: number) { page.value[tabKey] = p; loadAll() }
|
||||
function onSizeChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); loadAll() }
|
||||
|
||||
function openCreate(type: string) {
|
||||
dialogMode.value = 'create'; dialogType.value = type
|
||||
dialogTimeRange.value = null
|
||||
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [] }
|
||||
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' }
|
||||
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
|
||||
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
|
||||
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }; plannedVisitors.value = [] }
|
||||
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [], manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }; plannedVisitors.value = [] }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -294,16 +364,24 @@ const notesByDate = computed(() => {
|
||||
<template>
|
||||
<div class="workspace" v-loading="loading">
|
||||
<div class="page-head">
|
||||
<h2 class="page-title">我的工作数据</h2>
|
||||
<div class="page-head-row">
|
||||
<h2 class="page-title">我的工作数据</h2>
|
||||
<div class="page-head-controls">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" style="width:240px" value-format="YYYY-MM-DD" @change="onFilterChange" />
|
||||
<el-button v-if="dateRange" size="small" @click="dateRange=null;onFilterChange()">清除</el-button>
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:240px" @keyup.enter="onFilterChange" @clear="onFilterChange">
|
||||
<template #append><el-button size="small" @click="onFilterChange">搜索</el-button></template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- ═══ 拜访记录 ═══ -->
|
||||
<el-tab-pane label="拜访记录" name="visits">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('visit')">+ 新建拜访</el-button></div>
|
||||
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
|
||||
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
|
||||
<h4 class="date-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
@@ -340,7 +418,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 今日纪要 ═══ -->
|
||||
<el-tab-pane label="今日纪要" name="daily_notes">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('note')">+ 新建纪要</el-button></div>
|
||||
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
|
||||
<h4 class="date-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
|
||||
@@ -371,7 +448,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 工作计划 ═══ -->
|
||||
<el-tab-pane label="工作计划" name="work_plans">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('plan')">+ 新建计划</el-button></div>
|
||||
<el-table :data="workPlans" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('plan', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -403,7 +479,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 小微商机 ═══ -->
|
||||
<el-tab-pane label="小微商机" name="mini_biz">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('mini')">+ 新建商机</el-button></div>
|
||||
<el-table :data="miniBusiness" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('mini', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -437,7 +512,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 要客拜访 ═══ -->
|
||||
<el-tab-pane label="要客拜访" name="key_visits">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('key')">+ 新建要客拜访</el-button></div>
|
||||
<el-table :data="keyVisits" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('key', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -469,15 +543,38 @@ const notesByDate = computed(() => {
|
||||
</el-table>
|
||||
<div v-if="!keyVisits.length" class="empty">暂无数据</div>
|
||||
</el-tab-pane>
|
||||
<div style="margin-top:16px;display:flex;align-items:center;gap:12px">
|
||||
<el-pagination
|
||||
v-model:current-page="page[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
|
||||
:page-size="pageSize"
|
||||
:total="total[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
@current-change="(p: number) => onPageChange(activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits', p)"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
|
||||
<el-form label-position="top" v-if="form">
|
||||
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%" @change="onDialogCustomerChange">
|
||||
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%" @change="onDialogCustomerChange">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="(auth.isDirector || auth.isLeader) && ['visit','plan','mini','key','note'].includes(dialogType)" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
@@ -577,9 +674,15 @@ const notesByDate = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.page-head { margin-bottom: 20px; }
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||||
.page-head-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.page-head-controls { display: flex; align-items: center; gap: 8px; }
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||
.date-group { margin-bottom: 20px; }
|
||||
.date-title { display: flex; align-items: center; margin: 12px 0 8px; font-family: var(--font-heading); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -7,14 +7,16 @@ import { todayStr } from '@/utils'
|
||||
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
|
||||
import { useScreenshot } from '@/utils/screenshot'
|
||||
import api from '@/api/index'
|
||||
import { miniBusinessApi } from '@/api/miniBusiness'
|
||||
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const miniBusiness = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
|
||||
@@ -24,9 +26,22 @@ const form = ref<any>({})
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
const miniStatuses = ['跟进中', '已签约', '已流失']
|
||||
|
||||
// ── Detail + Follow-up logs ──
|
||||
const detailVisible = ref(false)
|
||||
const selectedBusiness = ref<any>(null)
|
||||
const logs = ref<any[]>([])
|
||||
const logForm = ref({ log_date: '', method: '电话', content: '' })
|
||||
const logMethods = ['电话', '微信', '上门', '邮件', '其他']
|
||||
const logMethodIcons: Record<string, string> = { '电话': '📞', '微信': '💬', '上门': '🏢', '邮件': '📧', '其他': '📋' }
|
||||
const logMethodColors: Record<string, string> = { '电话': '#5B7FA5', '微信': '#22c55e', '上门': '#4A6741', '邮件': '#C4934A', '其他': '#7B7568' }
|
||||
const logLoading = ref(false)
|
||||
const logSaving = ref(false)
|
||||
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const filterStatus = ref('跟进中')
|
||||
const searchText = ref('')
|
||||
function onSearch() { loadItems() }
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -36,10 +51,12 @@ const managerOptions = computed(() => {
|
||||
.sort()
|
||||
})
|
||||
|
||||
const statusOrder: Record<string, number> = { '跟进中': 0, '已签约': 1, '已流失': 2 }
|
||||
const filteredItems = computed(() => {
|
||||
let list = miniBusiness.value
|
||||
if (filterManager.value) list = list.filter((m: any) => (m.manager_name || '未知') === filterManager.value)
|
||||
if (filterStatus.value) list = list.filter((m: any) => m.status === filterStatus.value)
|
||||
list.sort((a: any, b: any) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9))
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -64,8 +81,13 @@ const managerSummary = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadItems(), loadCustomers(), loadManagerColors()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 500 }
|
||||
@@ -75,10 +97,34 @@ async function loadCustomers(q?: string) {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleCustomerSearch(query: string) {
|
||||
customerSearch.value = query
|
||||
if (query) { await loadCustomers(query) }
|
||||
else { await loadCustomers() }
|
||||
}
|
||||
|
||||
async function handleQuickCreate() {
|
||||
const name = customerSearch.value.trim()
|
||||
if (!name) { ElMessage.warning('请输入单位名称'); return }
|
||||
try {
|
||||
const res = await api.post('/customers/quick-create', null, { params: { name } })
|
||||
const newCust = res.data
|
||||
customers.value.unshift(newCust)
|
||||
form.value.customer_id = newCust.id
|
||||
customerSearch.value = ''
|
||||
ElMessage.success(`已创建客户:${name}`)
|
||||
await loadCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/mini-business/')
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/mini-business/', { params })
|
||||
miniBusiness.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -86,7 +132,7 @@ async function loadItems() {
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'
|
||||
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
|
||||
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -100,7 +146,9 @@ function openEdit(item: any) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (form.value.status === '已流失') form.value.expected_revenue_date = ''
|
||||
if (dialogMode.value === 'create') {
|
||||
await api.post('/mini-business/', form.value)
|
||||
} else {
|
||||
@@ -121,10 +169,50 @@ async function handleDelete(id: string) {
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
// ── Detail + Follow-up logs ──
|
||||
async function openDetail(item: any) {
|
||||
selectedBusiness.value = item
|
||||
detailVisible.value = true
|
||||
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
|
||||
await loadLogs()
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const res = await miniBusinessApi.getLogs(selectedBusiness.value.id)
|
||||
logs.value = res.data || []
|
||||
} catch (_) { logs.value = [] }
|
||||
finally { logLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleCreateLog() {
|
||||
if (!logForm.value.content.trim()) { ElMessage.warning('请输入跟进内容'); return }
|
||||
logSaving.value = true
|
||||
try {
|
||||
await miniBusinessApi.createLog(selectedBusiness.value.id, logForm.value)
|
||||
ElMessage.success('跟进记录已添加')
|
||||
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
|
||||
await loadLogs()
|
||||
} catch (e: any) { ElMessage.error('添加失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
finally { logSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleDeleteLog(logId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该跟进记录?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.deleteLog(logId)
|
||||
ElMessage.success('已删除')
|
||||
await loadLogs()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
async function quickStatusChange(row: any, newStatus: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
|
||||
await api.put(`/mini-business/${row.id}`, { status: newStatus })
|
||||
const payload: any = { status: newStatus }
|
||||
if (newStatus === '已流失') payload.expected_revenue_date = ''
|
||||
await api.put(`/mini-business/${row.id}`, payload)
|
||||
ElMessage.success('状态已更新')
|
||||
await loadItems()
|
||||
} catch (e: any) {
|
||||
@@ -170,6 +258,9 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
@@ -192,7 +283,7 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<el-table-column type="index" label="序号" width="50" />
|
||||
<el-table-column prop="customer_name" label="客户" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
|
||||
<el-link type="primary" :underline="false" @click="openDetail(row)">{{ row.customer_name }}</el-link>
|
||||
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
|
||||
<template #content>最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
|
||||
<span class="edit-indicator" title="有过修改">🕐</span>
|
||||
@@ -236,9 +327,20 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类型"><el-input v-model="form.product_type" placeholder="如:云专线、SD-WAN" /></el-form-item>
|
||||
@@ -261,6 +363,59 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Detail Dialog with Follow-up Timeline -->
|
||||
<el-dialog v-model="detailVisible" :title="selectedBusiness?.customer_name + ' — 商机跟单'" width="650px" v-if="selectedBusiness">
|
||||
<!-- Business Info -->
|
||||
<div class="detail-info">
|
||||
<div class="detail-row"><span class="detail-label">产品类型</span><span>{{ selectedBusiness.product_type }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">金额</span><span>{{ selectedBusiness.amount }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">状态</span><span :style="{ color: selectedBusiness.status === '已签约' ? '#4A6741' : selectedBusiness.status === '已流失' ? '#B8472E' : '#C4934A' }">{{ selectedBusiness.status }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">预计列收</span><span>{{ selectedBusiness.expected_revenue_date }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">客户经理</span><span class="mgr-tag" :style="getMgrStyle(selectedBusiness.manager_name, isLight)">{{ selectedBusiness.manager_name }}</span></div>
|
||||
<div class="detail-row" v-if="selectedBusiness.follow_up_detail"><span class="detail-label">商机概述</span><span>{{ selectedBusiness.follow_up_detail }}</span></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px">
|
||||
<el-button size="small" @click="detailVisible = false; openEdit(selectedBusiness)">编辑商机</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Follow-up Timeline -->
|
||||
<div class="log-section">
|
||||
<h4 class="log-title">跟进记录</h4>
|
||||
<div v-loading="logLoading" class="log-list">
|
||||
<div v-if="logs.length === 0 && !logLoading" class="log-empty">暂无跟进记录</div>
|
||||
<div v-for="l in logs" :key="l.id" class="log-item">
|
||||
<span class="log-icon">{{ logMethodIcons[l.method] || '📋' }}</span>
|
||||
<div class="log-body">
|
||||
<div class="log-header">
|
||||
<span class="log-date">{{ l.log_date }}</span>
|
||||
<span class="log-method-chip" :style="{ background: logMethodColors[l.method] || '#7B7568', color: '#fff', padding: '1px 8px', fontSize: '11px' }">{{ l.method }}</span>
|
||||
<span class="log-author">{{ l.created_by_name }}</span>
|
||||
</div>
|
||||
<div class="log-content">{{ l.content }}</div>
|
||||
</div>
|
||||
<el-button size="small" type="danger" text @click="handleDeleteLog(l.id)" class="log-del">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Log Form -->
|
||||
<div class="log-add">
|
||||
<div class="log-add-row">
|
||||
<el-date-picker v-model="logForm.log_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:140px" />
|
||||
<div class="log-method-chips">
|
||||
<button v-for="m in logMethods" :key="m" type="button" class="log-chip" :class="{ 'log-chip--active': logForm.method === m }" :style="logForm.method === m ? { background: logMethodColors[m], borderColor: logMethodColors[m], color: '#fff' } : {}" @click="logForm.method = m">{{ m }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-input v-model="logForm.content" type="textarea" :rows="2" placeholder="输入跟进内容..." size="small" style="margin-top:6px" />
|
||||
<el-button type="primary" size="small" :loading="logSaving" @click="handleCreateLog" style="margin-top:6px">添加跟进</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -284,4 +439,37 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
|
||||
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
|
||||
/* ── Detail Dialog ── */
|
||||
.detail-info { background: var(--c-bg-light, #faf9f6); border-radius: 6px; padding: 14px 18px; }
|
||||
.detail-row { display: flex; gap: 12px; padding: 4px 0; font-size: 13px; }
|
||||
.detail-label { color: var(--warm-gray); min-width: 70px; flex-shrink: 0; }
|
||||
|
||||
/* ── Log Timeline ── */
|
||||
.log-section { margin-top: 18px; border-top: 1px solid var(--warm-border); padding-top: 14px; }
|
||||
.log-title { margin: 0 0 12px; font-family: var(--font-heading); font-size: 16px; color: var(--ink); }
|
||||
.log-list { display: flex; flex-direction: column; gap: 10px; max-height: 300px; overflow-y: auto; margin-bottom: 14px; }
|
||||
.log-empty { text-align: center; color: var(--warm-gray); font-size: 13px; padding: 20px 0; }
|
||||
.log-item { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; background: var(--surface); border: 1px solid var(--warm-border); }
|
||||
.log-icon { font-size: 18px; flex-shrink: 0; margin-top: 2px; }
|
||||
.log-body { flex: 1; min-width: 0; }
|
||||
.log-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.log-date { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
|
||||
.log-author { font-size: 11px; color: var(--warm-gray); margin-left: auto; }
|
||||
.log-content { font-size: 13px; color: var(--c-text); line-height: 1.6; }
|
||||
.log-del { flex-shrink: 0; opacity: 0.5; }
|
||||
.log-del:hover { opacity: 1; }
|
||||
.log-method-chip { border-radius: 3px; white-space: nowrap; }
|
||||
|
||||
/* ── Add Log Form ── */
|
||||
.log-add { border-top: 1px solid var(--warm-border); padding-top: 12px; }
|
||||
.log-add-row { display: flex; gap: 8px; align-items: center; }
|
||||
.log-method-chips { display: flex; gap: 4px; }
|
||||
.log-chip { padding: 4px 10px; border: 1px solid var(--warm-border); background: var(--surface); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.log-chip:hover { border-color: var(--ink); }
|
||||
.log-chip--active { font-weight: 600; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
import { useThemeStore, themeLabels, type Theme } from '@/stores/theme'
|
||||
import { useThemeStore, themeLabels, type Theme, lightThemes, darkThemes } from '@/stores/theme'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const managers = ref<any[]>([])
|
||||
const selectedUserIds = ref<string[]>([])
|
||||
@@ -165,15 +167,30 @@ async function handleImportPreview() {
|
||||
</div>
|
||||
</template>
|
||||
<div class="theme-options">
|
||||
<label v-for="t in (['editorial', 'light'] as Theme[])" :key="t" class="theme-opt" :class="{ 'theme-opt--active': themeStore.currentTheme === t }" @click="themeStore.setTheme(t)">
|
||||
<span class="theme-opt-radio">{{ themeStore.currentTheme === t ? '●' : '○' }}</span>
|
||||
<span class="theme-opt-label">{{ themeLabels[t] }}</span>
|
||||
</label>
|
||||
<div class="theme-mode-row">
|
||||
<span class="theme-mode-label">模式</span>
|
||||
<button class="theme-mode-btn" @click="themeStore.toggleMode()">
|
||||
{{ themeStore.mode === 'light' ? '☀️ 亮色模式' : '🌙 深色模式' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="theme-sub-options">
|
||||
<label
|
||||
v-for="t in (themeStore.mode === 'light' ? lightThemes : darkThemes)"
|
||||
:key="t"
|
||||
class="theme-opt"
|
||||
:class="{ 'theme-opt--active': (themeStore.mode === 'light' ? themeStore.lightTheme : themeStore.darkTheme) === t }"
|
||||
@click="themeStore.mode === 'light' ? themeStore.setLightTheme(t) : themeStore.setDarkTheme(t)"
|
||||
>
|
||||
<span class="theme-opt-radio">{{ (themeStore.mode === 'light' ? themeStore.lightTheme : themeStore.darkTheme) === t ? '●' : '○' }}</span>
|
||||
<span class="theme-opt-label">{{ themeLabels[t] }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Manual Remind -->
|
||||
<el-card class="setting-card">
|
||||
<template v-if="auth.isDirector">
|
||||
<!-- Manual Remind -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||||
@@ -371,6 +388,7 @@ async function handleImportPreview() {
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -395,7 +413,12 @@ async function handleImportPreview() {
|
||||
.setting-label { font-family: var(--font-body); font-size: 13px; color: var(--ink); }
|
||||
|
||||
/* ── Theme Selector ── */
|
||||
.theme-options { display: flex; gap: 16px; }
|
||||
.theme-options { display: flex; flex-direction: column; gap: 12px; }
|
||||
.theme-mode-row { display: flex; align-items: center; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--warm-border); margin-bottom: 4px; }
|
||||
.theme-mode-label { font-family: var(--font-heading); font-size: 13px; color: var(--warm-gray); }
|
||||
.theme-mode-btn { font-family: var(--font-body); font-size: 14px; cursor: pointer; padding: 6px 16px; border: 1px solid var(--warm-border); border-radius: 6px; background: var(--c-primary-bg); color: var(--c-text); transition: all var(--transition); }
|
||||
.theme-mode-btn:hover { border-color: var(--gold); }
|
||||
.theme-sub-options { display: flex; gap: 12px; padding-left: 4px; }
|
||||
.theme-opt { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 16px; border: 1px solid var(--warm-border); transition: all var(--transition); }
|
||||
.theme-opt:hover { border-color: var(--ink); }
|
||||
.theme-opt--active { border-color: var(--ink); background: var(--c-primary-bg); }
|
||||
|
||||
@@ -14,6 +14,7 @@ const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const workPlans = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
|
||||
@@ -49,8 +50,11 @@ const filterStatus = ref('')
|
||||
const expandedSections = ref<Record<string, boolean>>({
|
||||
thisWeek: true, nextWeek: true, thisMonth: false, later: false, archived: false,
|
||||
})
|
||||
const searchText = ref('')
|
||||
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
function onSearch() { loadPlans() }
|
||||
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'计划中': '#4A6741',
|
||||
@@ -128,17 +132,69 @@ const managerSummary = computed(() => {
|
||||
|
||||
function resetFilters() { filterManager.value = ''; filterStatus.value = '' }
|
||||
|
||||
onMounted(async () => { await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()]) })
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try { const params: any = { page_size: 500 }; if (q) params.search = q; const res = await api.get('/customers/', { params }); customers.value = res.data.items || [] } catch (_) {}
|
||||
}
|
||||
async function loadPlans() { loading.value = true; try { const res = await api.get('/work-plans/'); workPlans.value = res.data } catch (_) {} finally { loading.value = false } }
|
||||
|
||||
function openCreate() { dialogMode.value = 'create'; form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }; dialogVisible.value = true }
|
||||
function openEdit(item: any) { dialogMode.value = 'edit'; form.value = { ...item }; if (item.customer_id && item.customer_name && !customers.value.find((c: any) => c.id === item.customer_id)) { customers.value.unshift({ id: item.customer_id, name: item.customer_name }) }; dialogVisible.value = true }
|
||||
async function handleCustomerSearch(query: string) {
|
||||
customerSearch.value = query
|
||||
if (query) { await loadCustomers(query) }
|
||||
else { await loadCustomers() }
|
||||
}
|
||||
|
||||
async function handleQuickCreate() {
|
||||
const name = customerSearch.value.trim()
|
||||
if (!name) { ElMessage.warning('请输入单位名称'); return }
|
||||
try {
|
||||
const res = await api.post('/customers/quick-create', null, { params: { name } })
|
||||
const newCust = res.data
|
||||
customers.value.unshift(newCust)
|
||||
form.value.customer_id = newCust.id
|
||||
customerSearch.value = ''
|
||||
ElMessage.success(`已创建客户:${name}`)
|
||||
await loadCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/work-plans/', { params })
|
||||
workPlans.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'
|
||||
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
dialogMode.value = 'edit'
|
||||
form.value = { ...item }
|
||||
if (item.customer_id && item.customer_name && !customers.value.find((c: any) => c.id === item.customer_id)) {
|
||||
customers.value.unshift({ id: item.customer_id, name: item.customer_name })
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (dialogMode.value === 'create') await api.post('/work-plans/', form.value)
|
||||
else await api.put(`/work-plans/${form.value.id}`, form.value)
|
||||
@@ -190,8 +246,21 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0,
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item"><label class="filter-label">客户经理</label><el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px"><el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" /></el-select></div>
|
||||
<div class="filter-item"><label class="filter-label">状态</label><el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px"><el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" /></el-select></div>
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">状态</label>
|
||||
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px">
|
||||
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button v-if="filterManager || filterStatus" size="small" plain @click="resetFilters">重置</el-button>
|
||||
<span class="filter-count">{{ workPlans.length }} 条</span>
|
||||
</div>
|
||||
@@ -246,10 +315,35 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0,
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位"><el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%"><el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" /></el-select></el-form-item>
|
||||
<el-form-item label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="工作计划"><el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="计划中" value="计划中" /><el-option label="已完成" value="已完成" /><el-option label="已取消" value="已取消" /></el-select></el-form-item>
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划拜访时间">
|
||||
<el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作计划">
|
||||
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="计划中" value="计划中" />
|
||||
<el-option label="已完成" value="已完成" />
|
||||
<el-option label="已取消" value="已取消" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="handleSave">保存</el-button></template>
|
||||
@@ -306,4 +400,9 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0,
|
||||
|
||||
/* ── Overdue Badge ── */
|
||||
.overdue-badge { display: inline-block; padding: 1px 7px; border-radius: 8px; background: var(--vermilion); color: #fff; font-family: var(--font-mono); font-size: 10px; white-space: nowrap; }
|
||||
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -121,6 +121,13 @@ async function handleDelete() {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">分类</span>
|
||||
|
||||
@@ -70,7 +70,9 @@ async function handleSubmit() {
|
||||
}
|
||||
router.push('/m/leaves')
|
||||
} catch (e: any) {
|
||||
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + (e.response?.data?.detail || e.message))
|
||||
const detail = e.response?.data?.detail
|
||||
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
|
||||
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + msg)
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -123,13 +125,13 @@ async function handleDelete() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">开始日期</span></template>
|
||||
<el-date-picker v-model="form.start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" style="width:100%" />
|
||||
<template #label><span class="form-label">开始日期 <span class="required-star">*</span></span></template>
|
||||
<el-date-picker v-model="form.start_date" type="date" placeholder="选择开始日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">结束日期</span></template>
|
||||
<el-date-picker v-model="form.end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" style="width:100%" />
|
||||
<template #label><span class="form-label">结束日期 <span class="required-star">*</span></span></template>
|
||||
<el-date-picker v-model="form.end_date" type="date" placeholder="选择结束日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
@@ -198,4 +200,5 @@ async function handleDelete() {
|
||||
.delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,9 @@ import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
@@ -55,18 +56,22 @@ const groupedPlans = computed(() => {
|
||||
const statusColors: Record<string, string> = {
|
||||
'计划中': '#4A6741', '已完成': '#5B7FA5', '已取消': '#909399',
|
||||
}
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/work-plans/', { params: { page: page.value, page_size: pageSize.value } })
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/work-plans/', { params })
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {} finally { loading.value = false }
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await api.delete(`/work-plans/${id}`); ElMessage.success('已删除'); await loadItems() }
|
||||
catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
@@ -104,6 +109,11 @@ async function confirmReschedule() {
|
||||
|
||||
<div class="auto-hint">💡 拜访客户后计划会自动完成,无需手动改状态</div>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无工作计划</p></div>
|
||||
|
||||
<div v-for="group in groupedPlans" :key="group.key" class="section">
|
||||
@@ -191,7 +201,6 @@ async function confirmReschedule() {
|
||||
.quick-reschedule-btn:hover { background: var(--gold); color: #fff; }
|
||||
.quick-cancel-btn { padding: 4px 10px; background: none; border: 1px solid var(--vermilion); color: var(--vermilion); font-family: var(--font-body); font-size: 11px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-cancel-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
|
||||
Reference in New Issue
Block a user