feat: 初始化 Vue 3 + TypeScript 项目并添加销售控制台页面

- 使用 Vue 3、TypeScript、Vite、Pinia 和 Vue Router 搭建项目基础结构
- 集成 Tailwind CSS 和 DaisyUI 作为 UI 框架
- 创建销售控制台主页面,包含客户列表、对话监控和客户画像面板
- 添加模拟数据以展示客户在不同销售阶段的流转状态
- 实现 AI/人工切换、消息发送、资料推送等核心交互功能
This commit is contained in:
2026-01-26 14:10:54 +08:00
commit ff47306fa2
19 changed files with 2876 additions and 0 deletions

36
247_Contry/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

6
247_Contry/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"recommendations": [
"Vue.volar",
"prettier.prettier-vscode"
]
}

42
247_Contry/README.md Normal file
View File

@@ -0,0 +1,42 @@
# 247_Contry
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```

1
247_Contry/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
247_Contry/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

37
247_Contry/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "247-contry",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"pinia": "^3.0.4",
"tailwindcss": "^4.1.18",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.4",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/tsconfig": "^0.8.1",
"daisyui": "^5.5.14",
"npm-run-all2": "^8.0.4",
"prettier": "3.7.4",
"typescript": "~5.9.3",
"vite": "^7.3.0",
"vite-plugin-vue-devtools": "^8.0.5",
"vue-tsc": "^3.2.1"
}
}

2272
247_Contry/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

11
247_Contry/src/App.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
<router-view></router-view>
</template>
<style scoped>
</style>

14
247_Contry/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'index',
component: () => import('@/views/index/index.vue'),
}
],
})
export default router

View File

@@ -0,0 +1,15 @@
// mockData.ts
export const salesStages = [
{ id: 'stage_1', name: '加微/浅建联', color: 'border-t-blue-500' },
{ id: 'stage_2', name: '填表单/出报告', color: 'border-t-purple-500' },
{ id: 'stage_3', name: '课前促到课', color: 'border-t-indigo-500' },
{ id: 'stage_4', name: '课中/直播监控', color: 'border-t-orange-500' }, // 重点监控区
{ id: 'stage_5', name: '待人工介入', color: 'border-t-red-500' }, // 异议/退费
{ id: 'stage_6', name: '成交/合同', color: 'border-t-green-500' }
];
export const customers = [
{ id: 1, name: '李妈妈', stage: 'stage_4', intent: '高', aiStatus: 'active', lastMsg: '孩子正在看直播,挺喜欢的', tags: ['Day1', '厌学'] },
{ id: 2, name: '王爸爸', stage: 'stage_5', intent: '中', aiStatus: 'frozen', lastMsg: '你们这个退费怎么退?', tags: ['价格敏感', '需人工'] },
{ id: 3, name: '张家长', stage: 'stage_1', intent: '低', aiStatus: 'active', lastMsg: '通过了好友请求', tags: ['新客'] },
];

2
247_Contry/src/style.css Normal file
View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

View File

@@ -0,0 +1,346 @@
<template>
<!-- 全局容器无滚动条 -->
<div class="flex h-screen w-screen bg-base-200 overflow-hidden font-sans text-base-content">
<!-- ========================================== -->
<!-- 1. 左侧客户导航列表 (保持不变) -->
<!-- ========================================== -->
<aside class="w-80 flex flex-col bg-base-100 border-r border-base-200 z-20 shrink-0">
<!-- 顶部 Header -->
<div class="h-16 flex items-center justify-between px-4 border-b border-base-200 shrink-0">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-white font-bold">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
</svg>
</div>
<span class="font-bold text-lg tracking-tight">AI 销售控制台</span>
</div>
</div>
<!-- 垂直列表 -->
<div class="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
<div v-for="stage in stages" :key="stage.id"
class="collapse collapse-arrow border border-base-200 bg-base-100 rounded-box"
:class="{ 'collapse-open': stage.id === activeStageId }">
<input type="radio" name="stage-accordion" :checked="stage.id === activeStageId"
@click="activeStageId = stage.id" />
<div
class="collapse-title min-h-[2.5rem] py-2 px-3 text-sm font-medium flex items-center justify-between hover:bg-base-200">
<span class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="getStageColorDot(stage.color)"></span>
{{ stage.name }}
</span>
<span class="opacity-50 text-xs">{{ getCustomersByStage(stage.id).length }}</span>
</div>
<div class="collapse-content px-0 pb-0">
<div class="flex flex-col gap-1 p-1 bg-base-200/50">
<div v-for="user in getCustomersByStage(stage.id)" :key="user.id"
@click="selectCustomer(user)"
class="p-3 rounded cursor-pointer border-l-4 transition-all hover:bg-base-100"
:class="selectedCustomer?.id === user.id ? 'bg-white border-primary shadow-sm' : 'border-transparent opacity-80'">
<div class="flex justify-between items-start mb-1">
<span class="font-bold text-sm">{{ user.name }}</span>
<span class="text-[10px] opacity-50">{{ user.time }}</span>
</div>
<div class="text-xs truncate opacity-70">{{ user.lastMsg }}</div>
<div class="mt-2 flex gap-1">
<div class="badge badge-xs border-0"
:class="user.aiStatus === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'">
{{ user.aiStatus === 'active' ? 'AI' : '人工' }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
<!-- ========================================== -->
<!-- 2. 中间核心对话监控 (放大版) -->
<!-- ========================================== -->
<main class="flex-1 flex flex-col bg-white border-r border-base-200 relative min-w-0">
<!-- 聊天头部 -->
<div class="h-16 flex items-center justify-between px-6 border-b border-base-200 bg-white shrink-0">
<div v-if="selectedCustomer">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span class="text-lg">{{ selectedCustomer.name[0] }}</span>
</div>
</div>
<div>
<div class="font-bold text-base">{{ selectedCustomer.name }}</div>
<div class="text-xs text-base-content/50 flex items-center gap-2">
<span>ID: {{ selectedCustomer.id }}</span>
<span class="badge badge-xs badge-ghost">{{ selectedCustomer.tags.join(', ') }}</span>
</div>
</div>
</div>
</div>
<!-- 核心开关 -->
<div v-if="selectedCustomer"
class="flex items-center gap-3 bg-base-100 px-4 py-2 rounded-full border border-base-200">
<div class="flex flex-col items-end">
<span class="text-xs font-bold" :class="isAiActive ? 'text-success' : 'text-warning'">
{{ isAiActive ? '● AI 自动托管中' : '● 人工强制接管' }}
</span>
</div>
<input type="checkbox" class="toggle toggle-success" v-model="isAiActive" />
</div>
</div>
<!-- 聊天记录区域 -->
<div class="flex-1 overflow-y-auto p-6 bg-slate-50 space-y-6 custom-scrollbar relative">
<div v-if="!selectedCustomer"
class="absolute inset-0 flex items-center justify-center text-base-content/30">
<div class="text-center">
<p>请选择左侧客户</p>
</div>
</div>
<template v-else>
<!-- 历史分割线 -->
<div class="divider text-xs text-base-content/30">历史记录</div>
<div class="chat chat-start">
<div class="chat-image avatar">
<div class="w-10 rounded-full bg-base-300 text-center leading-10 text-xs">{{
selectedCustomer.name[0] }}</div>
</div>
<div class="chat-header text-xs opacity-50 mb-1">家长</div>
<div class="chat-bubble bg-white text-base-content shadow-sm border border-base-200">
老师上次说的那个什么家庭教育直播几点开始啊我怕赶不上
</div>
</div>
<div class="chat chat-end">
<div class="chat-header text-xs opacity-50 mb-1">AI 助手</div>
<div class="chat-bubble chat-bubble-primary text-white shadow-md">
不用担心直播是晚上8点开始哦如果有事赶不上的话明天也会生成回放链接发给您的
</div>
<div class="chat-footer opacity-50 text-[10px] mt-1">AI 意图识别: 询问时间</div>
</div>
<div class="chat chat-start">
<div class="chat-image avatar">
<div class="w-10 rounded-full bg-base-300 text-center leading-10 text-xs">{{
selectedCustomer.name[0] }}</div>
</div>
<div class="chat-bubble bg-white text-base-content shadow-sm border border-base-200">
{{ selectedCustomer.lastMsg }}
</div>
</div>
<!-- 系统日志插入到聊天流中 (替代流程图) -->
<div class="w-full flex justify-center my-4">
<div
class="bg-base-200 text-base-content/60 text-xs px-3 py-1 rounded-full flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
系统: 检测到意向升级 ( -> )已推送到活跃区
</div>
</div>
</template>
</div>
<!-- 底部输入区域 -->
<div v-if="selectedCustomer" class="h-40 bg-white border-t border-base-200 p-4 shrink-0">
<div class="relative h-full">
<textarea
class="textarea textarea-bordered w-full h-full pr-24 resize-none text-base focus:outline-none focus:border-primary transition-colors"
:class="{ 'textarea-disabled bg-base-100': isAiActive }"
:placeholder="isAiActive ? '🚫 AI 托管中,输入框已锁定。请先切换到人工接管模式...' : '请输入回复内容,按 Enter 发送...'"
:disabled="isAiActive"></textarea>
<div class="absolute bottom-3 right-3 flex gap-2">
<button class="btn btn-sm btn-ghost" :disabled="isAiActive">😊</button>
<button class="btn btn-sm btn-primary px-6" :disabled="isAiActive">发送</button>
</div>
</div>
</div>
</main>
<!-- ========================================== -->
<!-- 3. 右侧控制面板与信息 (固定宽度) -->
<!-- ========================================== -->
<aside v-if="selectedCustomer" class="w-96 bg-base-100 border-l border-base-200 flex flex-col shrink-0">
<!-- 客户画像卡片 -->
<div class="p-5 border-b border-base-200 bg-base-50">
<h3 class="text-xs font-bold text-base-content/40 uppercase mb-4 tracking-wider">客户画像</h3>
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="bg-white border border-base-200 p-3 rounded-lg text-center shadow-sm">
<div class="text-xs text-base-content/60 mb-1">成交意向</div>
<div class="text-2xl font-black text-primary">{{ selectedCustomer.intent === '高' ? 92 : 65 }}
</div>
</div>
<div class="bg-white border border-base-200 p-3 rounded-lg text-center shadow-sm">
<div class="text-xs text-base-content/60 mb-1">当前阶段</div>
<div class="text-sm font-bold mt-1">{{stages.find(s => s.id ===
selectedCustomer.stage)?.name.split('/')[0]}}</div>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-xs">
<span class="opacity-60">微信来源</span>
<span>直播间转入</span>
</div>
<div class="flex justify-between text-xs">
<span class="opacity-60">孩子年级</span>
<span>初二 (14)</span>
</div>
<div class="flex flex-wrap gap-2 mt-3">
<span v-for="tag in selectedCustomer.tags" :key="tag"
class="badge badge-sm badge-outline bg-white">{{
tag }}</span>
</div>
</div>
</div>
<!-- 工具箱 (垂直排列) -->
<div class="flex-1 overflow-y-auto p-5 custom-scrollbar">
<h3 class="text-xs font-bold text-base-content/40 uppercase mb-4 tracking-wider">快捷操作</h3>
<!-- 警告提示 -->
<div v-if="isAiActive" class="alert alert-warning shadow-sm text-xs py-2 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>操作前请先关闭 AI</span>
</div>
<div class="space-y-4">
<!-- 资料推送组 -->
<div class="form-control">
<label class="label pt-0"><span class="label-text font-bold text-xs">推送资料</span></label>
<div class="join w-full">
<select class="select select-bordered select-sm join-item w-full text-xs"
:disabled="isAiActive">
<option selected>Day1 直播回放链接</option>
<option>厌学心理电子书</option>
<option>价格表单 (6800)</option>
</select>
<button class="btn btn-sm btn-primary join-item" :disabled="isAiActive">发送</button>
</div>
</div>
<!-- 常用话术组 -->
<div class="form-control">
<label class="label pt-0"><span class="label-text font-bold text-xs">常用话术</span></label>
<div class="flex flex-col gap-2">
<button
class="btn btn-sm btn-outline btn-block justify-start font-normal text-xs h-auto py-2"
:disabled="isAiActive">
"这确实让家长很头疼..." (共情)
</button>
<button
class="btn btn-sm btn-outline btn-block justify-start font-normal text-xs h-auto py-2"
:disabled="isAiActive">
"咱们课程主要解决..." (产品)
</button>
</div>
</div>
<div class="divider my-2"></div>
<!-- 强制干预 -->
<div class="grid grid-cols-2 gap-3">
<button class="btn btn-sm btn-warning btn-outline" :disabled="isAiActive">催付定金</button>
<button class="btn btn-sm btn-error btn-outline" :disabled="isAiActive">拉黑/终止</button>
</div>
</div>
</div>
<!-- 简易日志区 (替代流程图) -->
<div class="h-48 bg-base-900 border-t border-base-300 p-4 shrink-0 bg-base-200">
<h3 class="text-xs font-bold text-base-content/40 uppercase mb-2 tracking-wider flex justify-between">
近期动态
<span class="text-[10px] font-normal cursor-pointer hover:text-primary">查看全部 ></span>
</h3>
<div class="space-y-2 overflow-y-auto h-32 custom-scrollbar pr-1">
<div class="text-[11px] flex gap-2">
<span class="opacity-50 font-mono">10:42</span>
<span class="text-success">工具调用成功: 发送资料</span>
</div>
<div class="text-[11px] flex gap-2">
<span class="opacity-50 font-mono">10:40</span>
<span>AI 识别意图: 索要资料</span>
</div>
<div class="text-[11px] flex gap-2">
<span class="opacity-50 font-mono">09:15</span>
<span class="text-blue-500">人工介入: 冻结 AI</span>
</div>
</div>
</div>
</aside>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// --- 数据部分 (保持不变) ---
const stages = [
{ id: 'stage_1', name: '加微/浅建联', color: 'blue' },
{ id: 'stage_2', name: '填表单/报告', color: 'purple' },
{ id: 'stage_3', name: '课前促到课', color: 'indigo' },
{ id: 'stage_4', name: '课中/活跃', color: 'orange' },
{ id: 'stage_5', name: '待人工介入', color: 'red' },
{ id: 'stage_6', name: '成交/合同', color: 'green' },
];
const customers = [
{ id: 101, name: '李妈妈', stage: 'stage_4', intent: '高', aiStatus: 'active', lastMsg: '孩子正在看直播,挺喜欢的', tags: ['Day1', '厌学'], time: '1m' },
{ id: 102, name: '王爸爸', stage: 'stage_5', intent: '中', aiStatus: 'frozen', lastMsg: '你们这个退费怎么退?', tags: ['价格敏感'], time: '2h' },
{ id: 103, name: '张家长', stage: 'stage_1', intent: '低', aiStatus: 'active', lastMsg: '通过了好友请求', tags: ['新客'], time: '5h' },
{ id: 105, name: '刘女士', stage: 'stage_4', intent: '中', aiStatus: 'active', lastMsg: '还没收到报告', tags: ['急切'], time: '1d' },
];
const activeStageId = ref('stage_4');
const selectedCustomer = ref<any>(customers[0]);
const isAiActive = ref(true);
const getCustomersByStage = (stageId: string) => customers.filter(c => c.stage === stageId);
const getStageColorDot = (color: string) => {
const map: any = { blue: 'bg-blue-500', purple: 'bg-purple-500', indigo: 'bg-indigo-500', orange: 'bg-orange-500', red: 'bg-red-500', green: 'bg-green-500' };
return map[color] || 'bg-gray-500';
};
const selectCustomer = (user: any) => {
selectedCustomer.value = user;
isAiActive.value = user.aiStatus === 'active';
};
</script>
<style scoped>
/* 滚动条美化 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
</style>

View File

@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
247_Contry/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

19
247_Contry/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})