转换Vue3前置

This commit is contained in:
乘风
2026-01-12 11:56:34 +08:00
parent efe0685189
commit 9bd5807bdb
492 changed files with 6136 additions and 1781 deletions

View File

@@ -41,5 +41,6 @@
"typescript.suggest.autoImports": true,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false
"editor.detectIndentation": false,
"git.ignoreLimitWarning": true
}

View File

@@ -1,24 +1,28 @@
/* eslint-env node */
require('eslint-plugin-vue')
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
// './.eslintrc-auto-import.json', // 自动导入的全局变量(安装依赖后自动生成)
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
plugins: ['vue', '@typescript-eslint'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-unused-vars': 'warn',
'no-unused-vars': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
}

View File

@@ -0,0 +1,323 @@
# TouchVueThree 项目目录结构说明
> **PC 端微信客服系统 - Vue3 + TypeScript + Element Plus**
## 📁 目录结构
```
TouchVueThree/
├── public/ # 静态资源(不会被编译)
│ ├── favicon.ico
│ └── assets/ # 图片、表情包等静态资源
├── src/
│ ├── api/ # API 接口层
│ │ ├── request.ts # Axios 封装(拦截器、错误处理)
│ │ ├── interceptors.ts # 请求/响应拦截器
│ │ └── modules/ # 按业务模块划分的接口
│ │ ├── user.ts # 用户相关接口
│ │ ├── wechat.ts # 微信相关接口
│ │ ├── ai.ts # AI 相关接口
│ │ └── common.ts # 通用接口(文件上传等)
│ │
│ ├── assets/ # 资源文件
│ │ ├── images/ # 图片资源
│ │ ├── icons/ # 图标资源
│ │ └── styles/ # 样式文件
│ │ ├── variables.scss # 全局变量(颜色、字体)
│ │ ├── mixins.scss # SCSS 混入
│ │ ├── reset.scss # 样式重置
│ │ └── global.scss # 全局样式
│ │
│ ├── components/ # 公共组件
│ │ ├── common/ # 通用组件(与业务无关)
│ │ │ ├── Button/ # 自定义按钮
│ │ │ ├── Dialog/ # 自定义对话框
│ │ │ ├── Loading/ # 加载组件
│ │ │ ├── Empty/ # 空状态
│ │ │ └── ErrorBoundary/ # 错误边界
│ │ │
│ │ └── business/ # 业务组件(微信客服相关)
│ │ ├── ChatWindow/ # 聊天窗口
│ │ ├── ContactList/ # 联系人列表
│ │ ├── SessionList/ # 会话列表
│ │ ├── CustomerList/ # 客服账号列表
│ │ ├── ProfileCard/ # 客户画像卡片
│ │ ├── EmojiPicker/ # 表情选择器
│ │ ├── FileUpload/ # 文件上传
│ │ └── QuickReply/ # 快捷回复
│ │
│ ├── composables/ # 组合式函数Composition API
│ │ ├── core/ # 核心功能
│ │ │ ├── useAuth.ts # 认证登录
│ │ │ ├── useWebSocket.ts # WebSocket 管理
│ │ │ ├── useEventBus.ts # 事件总线
│ │ │ └── useRequest.ts # 请求封装
│ │ │
│ │ └── business/ # 业务功能
│ │ ├── useMessages.ts # 消息管理
│ │ ├── useContacts.ts # 联系人管理
│ │ ├── useSessions.ts # 会话管理
│ │ ├── useAi.ts # AI 功能
│ │ └── useUpload.ts # 文件上传
│ │
│ ├── directives/ # 自定义指令
│ │ ├── vLoading.ts # 加载指令
│ │ ├── vLazyLoad.ts # 懒加载指令
│ │ └── vPermission.ts # 权限指令
│ │
│ ├── layouts/ # 页面布局
│ │ ├── DefaultLayout.vue # 默认布局
│ │ └── ChatLayout.vue # 聊天布局(三栏)
│ │
│ ├── router/ # 路由配置
│ │ ├── index.ts # 路由主文件
│ │ ├── guards.ts # 路由守卫(权限控制)
│ │ └── routes.ts # 路由配置
│ │
│ ├── stores/ # Pinia 状态管理
│ │ ├── index.ts # Store 统一出口
│ │ └── modules/ # Store 模块
│ │ ├── user.ts # 用户状态
│ │ ├── app.ts # 应用全局状态
│ │ ├── websocket.ts # WebSocket 状态
│ │ └── wechat/ # 微信模块
│ │ ├── contacts.ts # 联系人
│ │ ├── messages.ts # 消息
│ │ ├── sessions.ts # 会话
│ │ └── ai.ts # AI
│ │
│ ├── types/ # TypeScript 类型定义
│ │ ├── global.d.ts # 全局类型
│ │ ├── api.d.ts # API 类型
│ │ ├── user.ts # 用户类型
│ │ ├── wechat.ts # 微信类型
│ │ └── websocket.ts # WebSocket 类型
│ │
│ ├── utils/ # 工具函数
│ │ ├── common.ts # 通用工具
│ │ ├── date.ts # 日期处理
│ │ ├── format.ts # 格式化
│ │ ├── validator.ts # 验证
│ │ ├── storage.ts # 本地存储
│ │ ├── event-bus.ts # 事件总线
│ │ └── sentry/ # 错误监控
│ │ └── index.ts
│ │
│ ├── views/ # 页面组件
│ │ ├── Login/ # 登录页
│ │ │ └── index.vue
│ │ │
│ │ ├── Chat/ # 聊天主页(核心功能)
│ │ │ ├── index.vue
│ │ │ └── components/ # 聊天页专用组件
│ │ │ ├── Sidebar.vue # 左侧边栏
│ │ │ ├── Main.vue # 中间聊天区
│ │ │ └── Panel.vue # 右侧面板
│ │ │
│ │ ├── Dashboard/ # 数据看板
│ │ │ └── index.vue
│ │ │
│ │ ├── Settings/ # 系统设置
│ │ │ └── index.vue
│ │ │
│ │ ├── PowerCenter/ # 能力中心
│ │ │ ├── CustomerManagement/ # 客户管理
│ │ │ ├── ContentManagement/ # 内容管理
│ │ │ ├── DataStatistics/ # 数据统计
│ │ │ └── AiTraining/ # AI 训练
│ │ │
│ │ └── 404/ # 404 页面
│ │ └── index.vue
│ │
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .eslintrc.cjs # ESLint 配置
├── .prettierrc # Prettier 配置
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
└── package.json # 项目配置
```
## 🎯 命名规范
### 文件命名
- **组件文件**PascalCase大驼峰
- `ChatWindow.vue`, `MessageList.vue`
- **工具文件**camelCase小驼峰
- `common.ts`, `useAuth.ts`
- **类型文件**camelCase 或 kebab-case
- `user.ts`, `api.d.ts`
### 目录命名
- **kebab-case**(短横线)或 **camelCase**
- `components/business`, `composables/core`
### 组件命名规则
```typescript
// ✅ 推荐
<script setup lang="ts" name="ChatWindow">
// 组件逻辑
</script>
// ❌ 避免
<script setup lang="ts">
// 没有 name 属性
</script>
```
## 🔧 开发规范
### 1. Composables 使用规范
```typescript
// composables/core/useAuth.ts
import { ref, computed } from 'vue'
import { useUserStore } from '@/stores'
export function useAuth() {
const userStore = useUserStore()
const isLoggedIn = computed(() => userStore.isLoggedIn)
const login = async (credentials) => {
// 登录逻辑
}
return {
isLoggedIn,
login
}
}
```
### 2. Store 使用规范
```typescript
// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const user = ref(null)
const token = ref('')
// Getters
const isLoggedIn = computed(() => !!token.value)
// Actions
const setUser = (userData) => {
user.value = userData
}
return { user, token, isLoggedIn, setUser }
}, {
persist: {
key: 'user-store',
paths: ['user', 'token']
}
})
```
### 3. API 调用规范
```typescript
// api/modules/user.ts
import request from '../request'
export const loginApi = (data: LoginParams) => {
return request('/auth/login', data, 'POST')
}
export const getUserInfoApi = () => {
return request('/user/info', {}, 'GET')
}
```
### 4. 组件引入规范
```vue
<script setup lang="ts">
// 1. Vue API
import { ref, computed, onMounted } from 'vue'
// 2. 第三方库
import { ElMessage } from 'element-plus'
// 3. Store
import { useUserStore } from '@/stores'
// 4. Composables
import { useAuth } from '@/composables/core/useAuth'
// 5. 类型
import type { User } from '@/types/user'
// 6. 组件
import ChatWindow from '@/components/business/ChatWindow/index.vue'
</script>
```
## 📝 注释规范
```typescript
/**
* 用户登录
* @param credentials 登录凭证
* @returns Promise<UserInfo>
*/
export async function login(credentials: LoginParams): Promise<UserInfo> {
// 实现逻辑
}
```
## 🚀 启动命令
```bash
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 类型检查
pnpm type-check
# 代码检查
pnpm lint
# 格式化代码
pnpm format
# 构建生产版本
pnpm build
# 预览生产版本
pnpm preview
# 打包分析
pnpm analyze
```
## 📌 下一步计划
1. ✅ 目录结构已创建
2. ✅ package.json 已优化
3. ⏳ 安装依赖:`pnpm install`
4. ⏳ 创建基础配置文件
5. ⏳ 开发核心功能
---
**更新时间**: 2026-01-12
**版本**: 2.0.0
**技术栈**: Vue 3.4 + TypeScript 5.4 + Element Plus 2.5 + Pinia 2.1

View File

@@ -20,6 +20,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"strict": false,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"],
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
},
"include": ["src/**/*.js", "src/**/*.vue", "env.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,27 +1,56 @@
{
"name": "touchvue-three",
"version": "1.0.0",
"version": "2.0.0",
"type": "module",
"private": true,
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.ts,.tsx --fix --ignore-path .gitignore",
"lint:check": "eslint . --ext .vue,.ts,.tsx",
"format": "prettier --write \"src/**/*.{vue,ts,tsx,js,json,scss,css}\"",
"format:check": "prettier --check \"src/**/*.{vue,ts,tsx,js,json,scss,css}\"",
"analyze": "vite build --mode analyze"
},
"dependencies": {
"vue": "^3.4.21"
"vue": "^3.4.21",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"element-plus": "^2.5.6",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.7",
"@tanstack/vue-query": "^5.20.0",
"@vueuse/core": "^10.7.2",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"vue-echarts": "^6.6.8",
"@sentry/vue": "^7.100.0",
"mitt": "^3.0.1",
"nanoid": "^5.0.4",
"lodash-es": "^4.17.21"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.18",
"vite": "^5.1.4",
"vue-tsc": "^1.8.27",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"typescript": "^5.4.5",
"@types/node": "^20.11.5",
"@types/lodash-es": "^4.17.12",
"sass": "^1.75.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.20.1",
"postcss": "^8.4.35",
"@typescript-eslint/parser": "^7.7.0",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",
"vite": "^5.1.4"
"vite-plugin-compression": "^0.5.1",
"rollup-plugin-visualizer": "^5.12.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,86 +1,84 @@
<template>
<div
class="flex h-screen w-screen bg-[#F8FAFC] text-slate-700 overflow-hidden font-sans"
>
<!-- 全局侧边栏 -->
<Sidebar
:current-tab="currentTab"
@change-tab="handleTabChange"
@logo-click="handleLogoClick"
@add-account="handleAddAccount"
@open-settings="handleOpenSettings"
@select-account="handleSelectAccount"
/>
<div class="app-container">
<div class="content">
<h1>Hello TouchVueThree</h1>
<p class="subtitle">Vue 3 + TypeScript + Element Plus</p>
<!-- 主内容区域 -->
<main class="flex-1 relative overflow-hidden">
<Transition name="fade" mode="out-in">
<component :is="activeComponent" />
</Transition>
</main>
<!-- Element Plus 示例组件 -->
<div class="demo-section">
<el-button type="primary" @click="handleClick">主要按钮</el-button>
<el-button type="success">成功按钮</el-button>
<el-button type="info">信息按钮</el-button>
<el-button type="warning">警告按钮</el-button>
<el-button type="danger">危险按钮</el-button>
</div>
<div class="demo-section">
<el-input v-model="inputValue" placeholder="请输入内容" clearable />
</div>
<div class="demo-section">
<el-tag>标签一</el-tag>
<el-tag type="success">标签二</el-tag>
<el-tag type="info">标签三</el-tag>
<el-tag type="warning">标签四</el-tag>
<el-tag type="danger">标签五</el-tag>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import Sidebar from './components/Sidebar.vue'
import ChatIndex from './views/Chat/Index.vue'
import Contacts from './views/Contacts.vue'
import Analytics from './views/Analytics.vue'
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const currentTab = ref('chat')
// TypeScript 类型定义
const inputValue = ref<string>('')
const activeComponent = computed(() => {
switch (currentTab.value) {
case 'chat':
return ChatIndex
case 'contacts':
return Contacts // 通讯录页面
case 'analytics':
return Analytics
default:
return ChatIndex
}
})
// 处理标签切换
const handleTabChange = (tab) => {
currentTab.value = tab
}
// 处理 Logo 点击
const handleLogoClick = () => {
// 可以跳转到首页或执行其他操作
currentTab.value = 'chat'
}
// 处理添加账号
const handleAddAccount = () => {
// TODO: 打开添加账号对话框或跳转到账号管理页面
console.log('添加账号')
currentTab.value = 'contacts'
}
// 处理打开设置
const handleOpenSettings = () => {
// TODO: 打开设置对话框或跳转到设置页面
console.log('打开设置')
}
// 处理选择账号
const handleSelectAccount = (account) => {
// TODO: 切换到选中的账号
console.log('选择账号:', account)
// 事件处理函数
const handleClick = (): void => {
ElMessage({
message: '恭喜你,转换成功!🎉',
type: 'success',
})
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
.content {
text-align: center;
background: white;
padding: 60px 80px;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
font-size: 42px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.subtitle {
font-size: 18px;
color: #666;
margin-bottom: 40px;
}
.demo-section {
margin: 30px 0;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,11 @@
// Element Plus 主题变量覆盖(可选)
// 更多变量请参考: https://element-plus.org/zh-CN/guide/theming.html
// 主题色
// $--el-color-primary: #409eff;
// 边框圆角
// $--el-border-radius-base: 4px;
// 字体
// $--el-font-size-base: 14px;

View File

@@ -1,28 +1,87 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 全局样式 - Vue 3 + TypeScript + Element Plus */
/* 自定义样式 */
body {
font-family: "Plus Jakarta Sans", "Inter", sans-serif;
/* CSS 重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* 全局字体 */
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "微软雅黑", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
/* 隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* 页面切换动画 */
.fade-in {
animation: fadeIn 0.3s ease-out forwards;
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
/* 自定义滚动条 */
.custom-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 3px;
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
background: #c0c4cc;
}
.custom-scroll::-webkit-scrollbar-track {
background: #f5f7fa;
}
/* 页面切换动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 移动端适配 */
.mobile-h-screen {
height: 100vh;
height: 100dvh; /* 解决移动端地址栏遮挡问题 */
}
/* 安全区域适配 */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
/* Element Plus 全局样式覆盖 */
:root {
--el-color-primary: #409eff;
--el-border-radius-base: 4px;
}
/* 响应式工具类 */
@media (max-width: 768px) {
.mobile-hidden {
display: none !important;
}
}
@media (min-width: 769px) {
.desktop-hidden {
display: none !important;
}
}

View File

@@ -1,65 +0,0 @@
<template>
<div class="group relative">
<!-- 胶囊主体 -->
<div
class="flex items-center gap-3 bg-gradient-to-r from-indigo-500 to-purple-600 text-white pl-4 pr-1 py-1 rounded-full shadow-md cursor-pointer hover:shadow-lg hover:-translate-y-0.5 transition-all"
>
<div class="flex flex-col leading-none items-end">
<span class="text-[9px] font-bold opacity-80 uppercase tracking-wide"
>AI 算力</span
>
<span class="text-sm font-bold font-mono">{{ animatedBalance }}</span>
</div>
<div
class="w-8 h-8 bg-white text-indigo-600 rounded-full flex items-center justify-center shadow-inner"
>
<i
class="fa-solid fa-bolt text-sm"
:class="{ 'animate-pulse': isAnimating }"
></i>
</div>
</div>
<!-- 悬浮详情 (Dropdown) -->
<div
class="absolute top-full right-0 mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-100 p-4 hidden group-hover:block z-50"
>
<div class="flex justify-between items-center mb-3">
<span class="text-xs font-bold text-gray-700">算力钱包</span>
<span
class="text-[10px] bg-green-100 text-green-700 px-2 py-0.5 rounded-full"
>状态正常</span
>
</div>
<div class="h-2 bg-gray-100 rounded-full mb-2 overflow-hidden">
<div class="h-full bg-indigo-500 w-3/4 rounded-full"></div>
</div>
<div class="text-xs text-gray-400 mb-3">本月额度剩余 75%</div>
<button
class="w-full py-1.5 bg-slate-800 text-white text-xs rounded-lg hover:bg-slate-700 transition"
>
立即充值
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
balance: { type: Number, default: 0 },
})
const isAnimating = ref(false)
const animatedBalance = computed(() => props.balance.toLocaleString())
// 暴露给父组件调用的方法:播放扣费动画
const playDeductAnimation = () => {
isAnimating.value = true
setTimeout(() => (isAnimating.value = false), 500)
}
defineExpose({ playDeductAnimation })
</script>

View File

@@ -1,183 +0,0 @@
<template>
<div
class="w-16 bg-slate-900 flex flex-col items-center py-6 flex-shrink-0 z-20"
>
<!-- Logo -->
<div
class="w-10 h-10 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-xl mb-8 shadow-lg cursor-pointer hover:scale-105 transition-transform"
@click="$emit('logo-click')"
>
<i class="fa-solid fa-robot"></i>
</div>
<!-- 功能模块 -->
<div class="space-y-6 flex flex-col items-center w-full">
<!-- 聚合聊天 -->
<div
@click="$emit('change-tab', 'chat')"
class="group relative cursor-pointer"
:class="
currentTab === 'chat'
? 'text-indigo-400'
: 'text-gray-500 hover:text-gray-300'
"
>
<i class="fa-solid fa-comments text-2xl transition-colors"></i>
<span
class="absolute left-14 bg-gray-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition whitespace-nowrap z-50 pointer-events-none"
>
聚合聊天
</span>
</div>
<!-- 通讯录 -->
<div
@click="$emit('change-tab', 'contacts')"
class="group relative cursor-pointer transition-colors"
:class="
currentTab === 'contacts'
? 'text-indigo-400'
: 'text-gray-500 hover:text-gray-300'
"
>
<i class="fa-solid fa-address-book text-xl"></i>
<span
class="absolute left-14 bg-gray-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition whitespace-nowrap z-50 pointer-events-none"
>
通讯录
</span>
</div>
<!-- 数据看板 -->
<div
@click="$emit('change-tab', 'analytics')"
class="group relative cursor-pointer transition-colors"
:class="
currentTab === 'analytics'
? 'text-indigo-400'
: 'text-gray-500 hover:text-gray-300'
"
>
<i class="fa-solid fa-chart-pie text-xl"></i>
<span
class="absolute left-14 bg-gray-800 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition whitespace-nowrap z-50 pointer-events-none"
>
数据看板
</span>
</div>
</div>
<!-- 分隔线 -->
<div class="mt-8 mb-4 w-8 border-b border-gray-700"></div>
<!-- 多账号头像 (模拟微信多开) -->
<div
class="space-y-4 flex flex-col items-center overflow-y-auto no-scrollbar flex-1 min-h-0"
>
<div
v-for="account in accounts"
:key="account.id"
@click="selectAccount(account.id)"
class="relative cursor-pointer group"
>
<img
:src="account.avatar"
:class="[
'w-10 h-10 rounded-full border-2 object-cover transition-all',
account.active
? 'border-green-500 opacity-100'
: 'border-transparent hover:border-gray-500 opacity-60 hover:opacity-100',
]"
:alt="account.name"
/>
<!-- 未读消息红点 -->
<div
v-if="account.hasNotification"
class="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-slate-900"
></div>
</div>
<!-- 添加账号按钮 -->
<div
@click="$emit('add-account')"
class="w-10 h-10 rounded-full bg-gray-800 flex items-center justify-center text-gray-400 hover:text-white cursor-pointer hover:bg-gray-700 transition"
>
<i class="fa-solid fa-plus"></i>
</div>
</div>
<!-- 底部设置 -->
<div class="mt-auto pb-4">
<div
@click="$emit('open-settings')"
class="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-gray-400 hover:text-white cursor-pointer transition-colors"
>
<i class="fa-solid fa-gear"></i>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
currentTab: {
type: String,
default: 'chat',
},
})
const emit = defineEmits([
'change-tab',
'logo-click',
'add-account',
'open-settings',
'select-account',
])
// 账号列表数据
const accounts = ref([
{
id: 'wx1',
name: '账号1',
avatar: 'https://i.pravatar.cc/150?u=wx1',
active: true,
hasNotification: true,
},
{
id: 'wx2',
name: '账号2',
avatar: 'https://i.pravatar.cc/150?u=wx2',
active: false,
hasNotification: false,
},
])
// 选择账号
const selectAccount = (accountId) => {
// 更新激活状态
accounts.value.forEach((account) => {
account.active = account.id === accountId
})
// 触发事件
const account = accounts.value.find((acc) => acc.id === accountId)
if (account) {
// 可以在这里清除通知
account.hasNotification = false
// 触发选择账号事件
emit('select-account', account)
}
}
</script>
<style scoped>
/* 自定义滚动条隐藏 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

21
TouchVueThree/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
// Element Plus 全局类型
declare module 'element-plus/global'
// 环境变量类型声明
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,6 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/style.css'
const app = createApp(App)
app.mount('#app')

18
TouchVueThree/src/main.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/style.css'
// Element Plus 样式(已配置自动导入,无需手动引入组件)
import 'element-plus/dist/index.css'
// Element Plus 图标(可选,按需导入)
// import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
// 注册所有图标(可选)
// for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
// app.component(key, component)
// }
app.mount('#app')

View File

@@ -1,35 +0,0 @@
<template>
<div class="p-8 h-full overflow-y-auto custom-scroll">
<div class="max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-8">
<h2 class="text-2xl font-bold text-slate-800">聚合账号管理</h2>
<button
class="bg-primary text-white px-6 py-2.5 rounded-xl font-bold shadow-lg shadow-indigo-200 flex items-center gap-2"
>
<i class="fa-solid fa-qrcode"></i> 扫码接入
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
class="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm hover:shadow-md transition-all relative overflow-hidden group"
>
<span
class="absolute top-3 right-3 bg-green-100 text-green-700 text-xs px-2 py-1 rounded-lg font-bold"
>在线</span
>
<div class="flex items-center gap-4 mb-6">
<img
src="https://i.pravatar.cc/150?u=wx1"
class="w-16 h-16 rounded-2xl border-2 border-gray-100"
/>
<div>
<h3 class="font-bold text-lg text-slate-800">商务-大刘</h3>
<p class="text-xs text-gray-400">ID: business_01</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,42 +0,0 @@
<template>
<div class="p-8 h-full overflow-y-auto custom-scroll">
<div class="max-w-6xl mx-auto">
<h2 class="text-2xl font-bold text-slate-800 mb-6">AI 效能看板</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div
class="bg-gradient-to-br from-[#4F46E5] to-[#7C3AED] rounded-2xl p-6 text-white shadow-lg relative overflow-hidden"
>
<div class="relative z-10">
<div class="text-indigo-100 text-sm font-medium mb-1">
AI 算力余额
</div>
<div class="text-4xl font-bold font-mono tracking-tight mb-4">
12,450
</div>
</div>
</div>
</div>
<div class="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm">
<h3 class="font-bold text-slate-800 mb-6">近7天接待趋势</h3>
<div class="h-64 flex items-end justify-between gap-2 px-2">
<div
v-for="h in [20, 30, 45, 25, 60, 40, 15]"
:key="h"
class="w-full flex flex-col gap-1 items-center group"
>
<div
:style="{ height: h + 'px' }"
class="w-4 bg-purple-400 rounded-t-sm opacity-80 group-hover:opacity-100 transition-all"
></div>
<div
:style="{ height: h / 2 + 'px' }"
class="w-4 bg-indigo-500 rounded-b-sm opacity-80 group-hover:opacity-100 transition-all"
></div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,95 +0,0 @@
<template>
<div
class="bg-white border-l border-gray-100 flex flex-col flex-shrink-0 transition-all duration-300 overflow-hidden"
:style="{ width: visible ? '300px' : '0', opacity: visible ? '1' : '0', pointerEvents: visible ? 'auto' : 'none' }"
>
<!-- Tab 切换 -->
<div class="flex p-1 bg-gray-50 m-4 rounded-lg border border-gray-100">
<button
@click="tab = 'profile'"
class="flex-1 py-1.5 text-xs font-bold rounded-md transition"
:class="
tab === 'profile'
? 'bg-white text-indigo-600 shadow-sm'
: 'text-gray-500'
"
>
资料
</button>
<button
@click="tab = 'scripts'"
class="flex-1 py-1.5 text-xs font-bold rounded-md transition"
:class="
tab === 'scripts'
? 'bg-white text-indigo-600 shadow-sm'
: 'text-gray-500'
"
>
话术
</button>
</div>
<div class="flex-1 overflow-y-auto px-5 pb-5 custom-scroll">
<!-- 资料内容 -->
<div v-if="tab === 'profile'" class="space-y-6">
<div class="text-center">
<img
src="https://i.pravatar.cc/150?u=8"
class="w-20 h-20 rounded-full border-4 border-white shadow-lg mx-auto"
/>
<h3 class="mt-2 font-bold text-slate-800">大野</h3>
<p class="text-xs text-gray-400">ID: ys666wow</p>
</div>
<div>
<h4 class="text-xs font-bold text-gray-400 uppercase mb-2">标签</h4>
<div class="flex flex-wrap gap-2">
<span
class="px-2 py-1 bg-red-50 text-red-600 text-xs rounded border border-red-100"
>高意向</span
>
<span
class="px-2 py-1 bg-blue-50 text-blue-600 text-xs rounded border border-blue-100"
>游戏玩家</span
>
</div>
</div>
<div
class="bg-yellow-50 p-3 rounded-lg border border-yellow-100 text-xs text-yellow-700"
>
<strong>跟进提醒</strong><br />明天上午10点确认订单情况
</div>
</div>
<!-- 话术内容 -->
<div v-else class="space-y-3">
<input
type="text"
placeholder="搜索话术..."
class="w-full bg-gray-50 text-xs py-2 px-3 rounded-lg border border-gray-200 mb-2"
/>
<div
v-for="i in 3"
:key="i"
class="p-3 bg-white border border-gray-100 rounded-xl hover:border-indigo-200 cursor-pointer group"
>
<h5
class="text-xs font-bold text-slate-700 mb-1 group-hover:text-indigo-600"
>
通用开场 {{ i }}
</h5>
<p class="text-xs text-gray-500 truncate">
您好我是您的专属顾问很高兴...
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({ visible: Boolean, chat: Object })
const tab = ref('profile')
</script>

View File

@@ -1,112 +0,0 @@
<template>
<div class="w-80 border-r border-gray-100 flex flex-col bg-white">
<!-- 搜索栏 -->
<div class="p-4 border-b border-gray-50">
<h2 class="font-bold text-lg text-slate-800 mb-3">消息 (12)</h2>
<div class="relative">
<i
class="fa-solid fa-magnifying-glass absolute left-3 top-3 text-gray-400 text-xs"
></i>
<input
type="text"
placeholder="搜索联系人..."
class="w-full bg-gray-50 text-sm pl-9 pr-4 py-2 rounded-lg border-transparent focus:bg-white focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-all outline-none"
/>
</div>
<!-- 快捷标签 -->
<div class="flex gap-2 mt-3 overflow-x-auto no-scrollbar pb-1">
<span
class="px-3 py-1 bg-indigo-50 text-indigo-600 text-xs font-bold rounded-md cursor-pointer whitespace-nowrap"
>全部</span
>
<span
class="px-3 py-1 bg-white border border-gray-200 text-gray-500 hover:bg-gray-50 text-xs rounded-md cursor-pointer whitespace-nowrap"
>未读</span
>
<span
class="px-3 py-1 bg-purple-50 text-purple-600 border border-purple-100 text-xs rounded-md cursor-pointer whitespace-nowrap flex items-center gap-1"
>
<i class="fa-solid fa-robot"></i> AI托管
</span>
</div>
</div>
<!-- 列表 -->
<div class="flex-1 overflow-y-auto custom-scroll">
<div
v-for="chat in chats"
:key="chat.id"
@click="$emit('select', chat.id)"
class="p-3 mx-2 my-1 rounded-xl cursor-pointer transition-all border flex gap-3 items-center"
:class="
activeId === chat.id
? 'bg-indigo-50 border-indigo-100'
: 'hover:bg-gray-50 border-transparent'
"
>
<div class="relative">
<img :src="chat.avatar" class="w-11 h-11 rounded-lg object-cover" />
<div
v-if="chat.unread"
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] h-4 min-w-[16px] px-1 flex items-center justify-center rounded-full border border-white"
>
{{ chat.unread }}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-baseline mb-1">
<h3 class="font-bold text-sm text-slate-700 truncate">
{{ chat.name }}
</h3>
<span class="text-[10px] text-gray-400">{{ chat.time }}</span>
</div>
<div class="flex items-center gap-1">
<span
v-if="chat.isAi"
class="bg-purple-100 text-purple-600 text-[9px] px-1 rounded font-bold"
>AI</span
>
<p class="text-xs text-gray-400 truncate">{{ chat.lastMsg }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps(['activeId'])
defineEmits(['select'])
const chats = ref([
{
id: 1,
name: '大野(朋友圈业务)',
avatar: 'https://i.pravatar.cc/150?u=8',
time: '11:45',
lastMsg: '已为您生成3条回复建议...',
unread: 0,
isAi: true,
},
{
id: 2,
name: '京东内购福利群',
avatar: 'https://i.pravatar.cc/150?u=group',
time: '10:20',
lastMsg: '张三: 谁有券?',
unread: 12,
isAi: false,
},
{
id: 3,
name: 'VIP客户-王总',
avatar: 'https://i.pravatar.cc/150?u=ceo',
time: '昨天',
lastMsg: '合同已确认',
unread: 0,
isAi: false,
},
])
</script>

View File

@@ -1,275 +0,0 @@
<template>
<div class="flex-1 flex flex-col min-w-0 bg-[#F8FAFC] relative">
<!-- Header (关键修改功能按钮 + 算力展示) -->
<header
class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shadow-sm z-10"
>
<!-- Left: Chat Info -->
<div class="flex items-center gap-4">
<h2 class="font-bold text-lg text-gray-800">大野(朋友圈业务)</h2>
<span
class="px-2 py-0.5 bg-green-50 text-green-600 text-xs rounded border border-green-200 flex items-center gap-1"
>
<span
class="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"
></span>
微信在线
</span>
</div>
<!-- Right: Functions & AI Credits -->
<div class="flex items-center gap-6">
<!-- Functional Buttons (Clean Style) -->
<div class="flex items-center gap-2 border-r border-gray-200 pr-6">
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 hover:text-indigo-600 hover:bg-indigo-50 rounded transition"
title="发朋友圈"
@click="$emit('post-moments')"
>
<i class="fa-brands fa-weixin"></i>
<span class="hidden xl:inline">发朋友圈</span>
</button>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 hover:text-indigo-600 hover:bg-indigo-50 rounded transition"
title="同步消息"
@click="$emit('sync-messages')"
>
<i class="fa-solid fa-arrows-rotate"></i>
<span class="hidden xl:inline">同步</span>
</button>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 hover:text-indigo-600 hover:bg-indigo-50 rounded transition"
title="创建任务"
@click="$emit('create-task')"
>
<i class="fa-regular fa-calendar-check"></i>
</button>
</div>
<!-- 算力胶囊 -->
<AICreditPill ref="creditPill" :balance="balance" />
<!-- Toggle Sidebar Info -->
<button
@click="$emit('toggle-detail')"
class="text-gray-400 hover:text-indigo-600 transition ml-2"
>
<i class="fa-solid fa-circle-info text-xl"></i>
</button>
</div>
</header>
<!-- 消息流 -->
<div
class="flex-1 overflow-y-auto p-6 space-y-6 custom-scroll"
ref="scrollContainer"
>
<div class="text-center">
<span
class="text-[10px] text-gray-400 bg-gray-100 px-3 py-1 rounded-full"
>今天 10:23</span
>
</div>
<!-- 消息列表 -->
<div
v-for="(msg, i) in messages"
:key="i"
class="flex gap-3 items-end group"
:class="{ 'flex-row-reverse': msg.isMe }"
>
<img
:src="
msg.isMe
? 'https://i.pravatar.cc/150?u=wx1'
: 'https://i.pravatar.cc/150?u=8'
"
class="w-8 h-8 rounded-lg shadow-sm"
/>
<div
class="flex flex-col gap-1 max-w-[70%]"
:class="{ 'items-end': msg.isMe }"
>
<span class="text-[10px] text-gray-400 mx-1">{{
msg.isMe ? '我' : '大野'
}}</span>
<!-- AI 标识 -->
<div v-if="msg.isAi" class="flex items-center gap-1 mb-0.5">
<span
class="text-[9px] bg-purple-50 text-purple-600 border border-purple-100 px-1 rounded"
>AI 生成</span
>
</div>
<div
class="p-3 rounded-2xl text-sm leading-relaxed shadow-sm relative"
:class="
msg.isMe
? 'bg-indigo-600 text-white rounded-br-none'
: 'bg-white text-slate-700 border border-gray-100 rounded-bl-none'
"
>
{{ msg.text }}
</div>
</div>
</div>
<!-- AI 建议卡片 (Interactive) -->
<div v-if="showAiCard" class="flex justify-end pr-12 animate-fade-in-up">
<div
class="bg-gradient-to-br from-purple-50 to-white border border-purple-100 rounded-xl p-4 shadow-lg max-w-md relative overflow-hidden"
>
<div class="flex justify-between items-center mb-2">
<div
class="flex items-center gap-2 text-purple-600 font-bold text-xs"
>
<i class="fa-solid fa-sparkles"></i> AI 建议回复
</div>
<span
class="text-[10px] text-orange-500 bg-orange-50 px-2 py-0.5 rounded border border-orange-100"
>预估消耗 5 算力</span
>
</div>
<p
class="text-sm text-slate-600 mb-3 bg-white/50 p-2 rounded border border-purple-50 border-dashed"
>
没问题的既然是老客户这次直接帮您申请赠送幻化服务现在下单的话我这就去安排打手准备
</p>
<div class="flex justify-end gap-2">
<button
@click="showAiCard = false"
class="px-3 py-1.5 text-xs text-gray-400 hover:text-gray-600"
>
忽略
</button>
<button
@click="useAiSuggestion"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs rounded-lg shadow-sm transition flex items-center gap-2"
>
<i class="fa-solid fa-paper-plane"></i> 发送
</button>
</div>
</div>
</div>
</div>
<!-- 输入框 -->
<div class="p-4 bg-white border-t border-gray-100 z-10">
<div class="flex justify-between items-center mb-2 px-1">
<div class="flex gap-4 text-gray-400">
<i
class="fa-regular fa-face-smile hover:text-indigo-600 cursor-pointer"
></i>
<i
class="fa-solid fa-folder-open hover:text-indigo-600 cursor-pointer"
></i>
</div>
<div
class="flex items-center gap-2 cursor-pointer"
@click="aiMode = !aiMode"
>
<span class="text-xs font-bold text-slate-500">AI 辅助</span>
<div
class="w-8 h-4 bg-gray-200 rounded-full relative transition-colors"
:class="{ 'bg-indigo-600': aiMode }"
>
<div
class="w-3 h-3 bg-white rounded-full absolute top-0.5 transition-all shadow-sm"
:class="aiMode ? 'right-0.5' : 'left-0.5'"
></div>
</div>
</div>
</div>
<div class="relative">
<textarea
v-model="inputMsg"
class="w-full h-20 bg-gray-50 border border-gray-200 rounded-xl p-3 text-sm focus:bg-white focus:border-indigo-500 focus:outline-none resize-none transition"
placeholder="输入消息..."
></textarea>
<button
@click="sendMsg"
class="absolute bottom-3 right-3 bg-slate-900 text-white px-4 py-1.5 rounded-lg text-xs font-bold hover:bg-slate-700 transition"
>
发送
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import AICreditPill from '../../components/AICreditPill.vue'
defineEmits([
'toggle-detail',
'post-moments',
'sync-messages',
'create-task',
])
const creditPill = ref(null)
const scrollContainer = ref(null)
const balance = ref(12450)
const showAiCard = ref(true)
const aiMode = ref(true)
const inputMsg = ref('')
const messages = ref([
{
text: '老板,上次说的那个魔兽代练套餐,如果是老客户的话,还能再送一次幻化吗?',
isMe: false,
isAi: false,
},
])
const scrollToBottom = () => {
nextTick(() => {
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
})
}
const useAiSuggestion = () => {
// 1. 扣费动画
creditPill.value?.playDeductAnimation()
balance.value -= 5
// 2. 发送消息
messages.value.push({
text: '亲,没问题的!既然是老客户,这次直接帮您申请赠送幻化服务。现在下单的话,我这就去安排打手准备。',
isMe: true,
isAi: true,
})
// 3. 隐藏卡片
showAiCard.value = false
scrollToBottom()
}
const sendMsg = () => {
if (!inputMsg.value.trim()) return
messages.value.push({ text: inputMsg.value, isMe: true, isAi: false })
inputMsg.value = ''
scrollToBottom()
}
</script>
<style>
.animate-fade-in-up {
animation: fadeInUp 0.4s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,59 +0,0 @@
<template>
<div class="flex h-full w-full">
<ChatList @select="selectChat" :activeId="activeChatId" />
<ChatWindow
:chat="activeChat"
:credit-balance="creditBalance"
@deduct-credit="handleCreditDeduction"
@toggle-detail="showDetail = !showDetail"
/>
<ChatDetail :visible="showDetail" :chat="activeChat" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import ChatList from './ChatList.vue'
import ChatWindow from './ChatWindow.vue'
import ChatDetail from './ChatDetail.vue'
const showDetail = ref(false)
const activeChatId = ref(1)
const creditBalance = ref(12450)
// 模拟数据
const chats = [
{
id: 1,
name: '大野(朋友圈业务)',
avatar: 'https://i.pravatar.cc/150?u=8',
time: '11:45',
msg: '已为您生成 3 条回复建议...',
isAi: true,
unread: 0,
type: 'user',
},
{
id: 2,
name: '京东内购福利群',
avatar: 'https://i.pravatar.cc/150?u=group',
time: '10:20',
msg: '张三: [图片] 谁有这个券?',
isAi: false,
unread: 99,
type: 'group',
},
]
const activeChat = computed(() =>
chats.find((c) => c.id === activeChatId.value)
)
const selectChat = (id) => {
activeChatId.value = id
}
const handleCreditDeduction = (amount) => {
creditBalance.value -= amount
}
</script>

View File

@@ -1,306 +0,0 @@
<template>
<div class="h-full overflow-y-auto custom-scroll bg-[#F8FAFC]">
<div class="p-8 max-w-7xl mx-auto">
<!-- 头部 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800 mb-1">通讯录管理</h1>
<p class="text-sm text-gray-500">管理您的所有联系人</p>
</div>
<div class="flex items-center gap-3">
<div class="relative">
<i
class="fa-solid fa-magnifying-glass absolute left-3 top-3 text-gray-400 text-xs"
></i>
<input
v-model="searchQuery"
type="text"
placeholder="搜索联系人..."
class="bg-white border border-gray-200 text-sm pl-9 pr-4 py-2 rounded-lg focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-all outline-none w-64"
/>
</div>
<button
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 text-sm font-medium"
>
<i class="fa-solid fa-plus"></i>
添加联系人
</button>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div
class="bg-white rounded-xl p-4 border border-gray-100 shadow-sm hover:shadow-md transition-shadow"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">总联系人</p>
<p class="text-2xl font-bold text-slate-800">{{ contacts.length }}</p>
</div>
<div
class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center"
>
<i class="fa-solid fa-address-book text-indigo-600 text-xl"></i>
</div>
</div>
</div>
<div
class="bg-white rounded-xl p-4 border border-gray-100 shadow-sm hover:shadow-md transition-shadow"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">最近联系</p>
<p class="text-2xl font-bold text-slate-800">
{{ recentContactsCount }}
</p>
</div>
<div
class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center"
>
<i class="fa-solid fa-clock text-green-600 text-xl"></i>
</div>
</div>
</div>
<div
class="bg-white rounded-xl p-4 border border-gray-100 shadow-sm hover:shadow-md transition-shadow"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">未读消息</p>
<p class="text-2xl font-bold text-slate-800">{{ unreadCount }}</p>
</div>
<div
class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center"
>
<i class="fa-solid fa-envelope text-red-600 text-xl"></i>
</div>
</div>
</div>
</div>
<!-- 联系人列表 -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div class="p-4 border-b border-gray-100 flex items-center gap-2">
<span
v-for="tag in tags"
:key="tag.id"
@click="selectedTag = tag.id"
:class="[
'px-3 py-1.5 text-xs font-medium rounded-lg cursor-pointer transition-colors',
selectedTag === tag.id
? 'bg-indigo-100 text-indigo-700'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100',
]"
>
{{ tag.name }}
<span
v-if="tag.count"
class="ml-1 text-xs"
:class="selectedTag === tag.id ? 'text-indigo-500' : 'text-gray-400'"
>
({{ tag.count }})
</span>
</span>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="contact in filteredContacts"
:key="contact.id"
class="p-4 border border-gray-100 rounded-xl flex items-center gap-4 hover:shadow-md hover:border-indigo-200 transition-all bg-white group cursor-pointer"
@click="openChat(contact)"
>
<div class="relative">
<img
:src="contact.avatar"
class="w-12 h-12 rounded-full object-cover ring-2 ring-white group-hover:ring-indigo-100 transition-all"
:alt="contact.name"
/>
<div
v-if="contact.isOnline"
class="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full border-2 border-white"
></div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<div class="font-semibold text-slate-800 truncate">
{{ contact.name }}
</div>
<span
v-if="contact.hasUnread"
class="w-2 h-2 bg-red-500 rounded-full flex-shrink-0"
></span>
</div>
<div class="text-xs text-gray-500 truncate">
{{ contact.lastContact || '暂无联系记录' }}
</div>
<div class="text-xs text-gray-400 mt-0.5">
{{ contact.phone || '未绑定手机号' }}
</div>
</div>
<button
@click.stop="startChat(contact)"
class="opacity-0 group-hover:opacity-100 text-indigo-600 hover:bg-indigo-50 p-2 rounded-lg transition-all"
title="发起聊天"
>
<i class="fa-regular fa-comment-dots"></i>
</button>
</div>
</div>
<!-- 空状态 -->
<div
v-if="filteredContacts.length === 0"
class="text-center py-12 text-gray-400"
>
<i class="fa-solid fa-address-book text-4xl mb-3 opacity-50"></i>
<p class="text-sm">暂无联系人</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const searchQuery = ref('')
const selectedTag = ref('all')
// 标签列表
const tags = ref([
{ id: 'all', name: '全部', count: 0 },
{ id: 'recent', name: '最近联系', count: 0 },
{ id: 'unread', name: '未读消息', count: 0 },
])
// 联系人数据
const contacts = ref([
{
id: 1,
name: '客户 1',
avatar: 'https://i.pravatar.cc/150?u=11',
phone: '138****8888',
lastContact: '最后联系3天前',
isOnline: true,
hasUnread: true,
},
{
id: 2,
name: '客户 2',
avatar: 'https://i.pravatar.cc/150?u=12',
phone: '139****9999',
lastContact: '最后联系5天前',
isOnline: false,
hasUnread: false,
},
{
id: 3,
name: '客户 3',
avatar: 'https://i.pravatar.cc/150?u=13',
phone: '137****7777',
lastContact: '最后联系1周前',
isOnline: true,
hasUnread: true,
},
{
id: 4,
name: '客户 4',
avatar: 'https://i.pravatar.cc/150?u=14',
phone: '136****6666',
lastContact: '最后联系2周前',
isOnline: false,
hasUnread: false,
},
{
id: 5,
name: '客户 5',
avatar: 'https://i.pravatar.cc/150?u=15',
phone: '135****5555',
lastContact: '最后联系1个月前',
isOnline: false,
hasUnread: false,
},
{
id: 6,
name: '客户 6',
avatar: 'https://i.pravatar.cc/150?u=16',
phone: '134****4444',
lastContact: '最后联系:今天',
isOnline: true,
hasUnread: false,
},
])
// 计算属性
const recentContactsCount = computed(() => {
return contacts.value.filter((c) => c.lastContact.includes('天')).length
})
const unreadCount = computed(() => {
return contacts.value.filter((c) => c.hasUnread).length
})
// 更新标签计数
tags.value[0].count = contacts.value.length
tags.value[1].count = recentContactsCount.value
tags.value[2].count = unreadCount.value
// 过滤联系人
const filteredContacts = computed(() => {
let result = contacts.value
// 按标签过滤
if (selectedTag.value === 'recent') {
result = result.filter((c) => c.lastContact.includes('天'))
} else if (selectedTag.value === 'unread') {
result = result.filter((c) => c.hasUnread)
}
// 按搜索关键词过滤
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
c.phone?.includes(query) ||
''
)
}
return result
})
// 打开聊天
const openChat = (contact) => {
// TODO: 跳转到聊天页面并打开该联系人
console.log('打开聊天:', contact)
}
// 发起聊天
const startChat = (contact) => {
// TODO: 发起与联系人的聊天
console.log('发起聊天:', contact)
}
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scroll::-webkit-scrollbar-track {
background: transparent;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 3px;
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
</style>

View File

@@ -1,18 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: '#4F46E5', // Indigo 600
primaryLight: '#EEF2FF',
accent: '#8B5CF6', // Violet 500
accentLight: '#F3E8FF',
},
fontFamily: {
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,51 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path Alias */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Types */
"types": [
"vite/client",
"element-plus/global",
"node"
]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,38 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
open: true,
port: 5173,
host: '0.0.0.0',
},
build: {
outDir: 'dist',
assetsDir: 'assets',
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue'],
},
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
},
minify: 'esbuild',
sourcemap: false,
},
})

View File

@@ -0,0 +1,159 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { fileURLToPath } from 'url'
// Element Plus 自动导入
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// 性能优化插件
import compression from 'vite-plugin-compression'
import { visualizer } from 'rollup-plugin-visualizer'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
return {
plugins: [
vue(),
// 自动导入 Vue 3 API、Vue Router、Pinia、VueUse、TanStack Query
AutoImport({
imports: [
'vue',
'vue-router',
'pinia',
'@vueuse/core',
{
'@tanstack/vue-query': ['useQuery', 'useMutation', 'useQueryClient', 'useInfiniteQuery']
}
],
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts',
eslintrc: {
enabled: true,
filepath: './.eslintrc-auto-import.json',
globalsPropValue: true,
},
}),
// 自动导入 Element Plus 组件
Components({
resolvers: [
ElementPlusResolver({
importStyle: 'sass', // 使用 SCSS 样式
}),
],
dts: 'src/components.d.ts',
}),
// Gzip 压缩
compression({
algorithm: 'gzip',
ext: '.gz',
threshold: 10240, // 大于 10KB 的文件才压缩
}),
// 打包分析(只在 analyze 模式下启用)
mode === 'analyze' && visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html'
}),
].filter(Boolean),
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@api': path.resolve(__dirname, './src/api'),
'@components': path.resolve(__dirname, './src/components'),
'@composables': path.resolve(__dirname, './src/composables'),
'@stores': path.resolve(__dirname, './src/stores'),
'@utils': path.resolve(__dirname, './src/utils'),
'@types': path.resolve(__dirname, './src/types'),
'@views': path.resolve(__dirname, './src/views'),
'@assets': path.resolve(__dirname, './src/assets'),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/assets/styles/variables.scss" as *;`,
api: 'modern-compiler'
},
},
},
server: {
open: true,
port: 8888,
host: '0.0.0.0',
proxy: {
'/api': {
target: process.env.VITE_API_BASE_URL || 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
manualChunks: {
// 核心框架
'vue-vendor': ['vue', 'vue-router', 'pinia'],
// UI 组件库
'element-plus': ['element-plus', '@element-plus/icons-vue'],
// 工具库
'utils': ['axios', 'dayjs', '@vueuse/core', 'lodash-es', 'mitt', 'nanoid'],
// 数据管理
'query': ['@tanstack/vue-query'],
// 图表库
'echarts': ['echarts', 'vue-echarts'],
},
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
},
minify: 'esbuild',
target: 'es2015',
},
// 依赖预构建优化
optimizeDeps: {
include: [
'vue',
'vue-router',
'pinia',
'pinia-plugin-persistedstate',
'element-plus',
'@element-plus/icons-vue',
'@vueuse/core',
'axios',
'dayjs',
'echarts',
'lodash-es',
'mitt',
'nanoid'
],
},
}
})

View File

@@ -0,0 +1,640 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>触客宝 Pro - 全端自适应版</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<!-- Google Fonts -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
-webkit-tap-highlight-color: transparent;
}
/* 隐藏滚动条但保留滚动功能 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 移动端平滑过渡动画 */
.slide-enter {
transform: translateX(100%);
}
.slide-active {
transform: translateX(0);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* PC端自定义细滚动条 */
@media (min-width: 768px) {
.custom-scroll::-webkit-scrollbar {
width: 6px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 3px;
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
}
/* 移动端适配调整 */
.mobile-h-screen {
height: 100vh;
height: 100dvh;
} /* 解决移动端地址栏遮挡问题 */
</style>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#4F46E5', // 品牌蓝
primaryLight: '#EEF2FF',
},
screens: {
xs: '480px', // 超小屏幕断点
},
},
},
}
</script>
</head>
<body
class="bg-gray-100 text-slate-700 overflow-hidden mobile-h-screen flex flex-col md:flex-row"
>
<!-- 1. PC/平板端左侧边栏 (Desktop Sidebar) -->
<!-- 在移动端隐藏 (hidden), 中等屏幕以上显示 (md:flex) -->
<aside
class="hidden md:flex w-[72px] bg-[#0F172A] flex-col items-center py-6 flex-shrink-0 z-40"
>
<div
class="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-white text-lg shadow-lg mb-8 cursor-pointer hover:scale-110 transition"
>
<i class="fa-solid fa-robot"></i>
</div>
<nav class="space-y-6 w-full flex flex-col items-center">
<div
class="w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center text-white cursor-pointer relative group"
>
<i class="fa-solid fa-comment-dots"></i>
<div
class="absolute left-0 top-1/2 -translate-y-1/2 h-6 w-1 bg-white rounded-r"
></div>
</div>
<div
class="w-10 h-10 rounded-lg flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/5 cursor-pointer transition"
>
<i class="fa-solid fa-address-book"></i>
</div>
<div
class="w-10 h-10 rounded-lg flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/5 cursor-pointer transition"
>
<i class="fa-solid fa-chart-pie"></i>
</div>
</nav>
<div class="mt-auto flex flex-col items-center gap-4 pb-4">
<img
src="https://i.pravatar.cc/150?u=wx1"
class="w-9 h-9 rounded-lg border-2 border-green-500 cursor-pointer"
/>
<button class="text-slate-500 hover:text-white">
<i class="fa-solid fa-gear text-lg"></i>
</button>
</div>
</aside>
<!-- 2. 中间区域:聊天列表 (Chat List) -->
<!-- 移动端:默认显示 (w-full). PC端固定宽度 (md:w-80) -->
<!-- 当移动端进入聊天详情时这里会被覆盖或隐藏通过JS控制 'mobile-hidden' 类 -->
<div
id="chatListPanel"
class="w-full md:w-80 bg-white border-r border-gray-200 flex flex-col flex-shrink-0 z-30 transition-transform duration-300"
>
<!-- 头部 -->
<div class="p-4 border-b border-gray-100">
<div class="flex justify-between items-center mb-4">
<h2 class="font-bold text-xl text-slate-800">消息</h2>
<!-- 移动端显示的头像 (代替PC侧边栏) -->
<div class="md:hidden">
<img
src="https://i.pravatar.cc/150?u=wx1"
class="w-8 h-8 rounded-full border border-gray-200"
/>
</div>
</div>
<!-- 搜索 -->
<div class="relative">
<i
class="fa-solid fa-magnifying-glass absolute left-3 top-3 text-gray-400 text-sm"
></i>
<input
type="text"
placeholder="搜索..."
class="w-full bg-slate-50 border-none rounded-xl pl-10 pr-4 py-2.5 text-sm focus:ring-2 focus:ring-primary/20 transition"
/>
</div>
<!-- 标签 -->
<div class="flex gap-2 mt-3 overflow-x-auto no-scrollbar">
<span
class="px-3 py-1 bg-primaryLight text-primary text-xs font-bold rounded-lg whitespace-nowrap"
>全部</span
>
<span
class="px-3 py-1 bg-gray-50 text-gray-500 text-xs font-medium rounded-lg whitespace-nowrap"
>未读 (12)</span
>
<span
class="px-3 py-1 bg-purple-50 text-purple-600 text-xs font-bold rounded-lg whitespace-nowrap flex items-center gap-1"
>
<i class="fa-solid fa-wand-magic-sparkles"></i> AI托管
</span>
</div>
</div>
<!-- 列表内容 -->
<div class="flex-1 overflow-y-auto custom-scroll pb-20 md:pb-0">
<!-- 聊天项 1 -->
<div
onclick="openChat('user1')"
class="p-4 border-b border-gray-50 hover:bg-gray-50 cursor-pointer flex gap-3 items-center active:bg-gray-100 transition"
>
<div class="relative">
<img
src="https://i.pravatar.cc/150?u=8"
class="w-12 h-12 rounded-xl object-cover"
/>
<div
class="absolute -top-1 -right-1 bg-primary text-white text-[10px] px-1.5 rounded-full border border-white"
>
AI
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-baseline mb-1">
<h3 class="font-bold text-slate-800 text-sm truncate">
大野(朋友圈业务)
</h3>
<span class="text-xs text-gray-400">11:45</span>
</div>
<p class="text-sm text-primary truncate">
已为您生成 3 条回复建议...
</p>
</div>
</div>
<!-- 聊天项 2 -->
<div
onclick="openChat('group1')"
class="p-4 border-b border-gray-50 hover:bg-gray-50 cursor-pointer flex gap-3 items-center active:bg-gray-100 transition"
>
<div class="relative">
<img
src="https://i.pravatar.cc/150?u=group"
class="w-12 h-12 rounded-xl object-cover grayscale opacity-80"
/>
<div
class="absolute -top-1 -right-1 bg-red-500 text-white text-[10px] w-5 h-5 flex items-center justify-center rounded-full border border-white"
>
99
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-baseline mb-1">
<h3 class="font-bold text-slate-700 text-sm truncate">
京东内购福利群
</h3>
<span class="text-xs text-gray-400">10:20</span>
</div>
<p class="text-sm text-gray-500 truncate">
张三: [图片] 谁有这个券?
</p>
</div>
</div>
</div>
</div>
<!-- 3. 主聊天窗口 (Chat Window) -->
<!-- 移动端:默认隐藏 (fixed inset-0 translate-x-full). PC端默认显示 (md:relative md:translate-x-0) -->
<main
id="chatWindow"
class="fixed inset-0 z-50 md:static md:inset-auto bg-[#F8FAFC] flex flex-col flex-1 transform translate-x-full md:translate-x-0 transition-transform duration-300 md:flex"
>
<!-- 聊天顶部 Header -->
<header
class="h-14 md:h-16 bg-white/90 backdrop-blur border-b border-gray-200 flex items-center justify-between px-4 sticky top-0 z-10"
>
<div class="flex items-center gap-3">
<!-- 移动端返回按钮 -->
<button
onclick="closeChat()"
class="md:hidden w-8 h-8 -ml-2 flex items-center justify-center text-slate-600 active:bg-gray-100 rounded-full"
>
<i class="fa-solid fa-chevron-left"></i>
</button>
<div>
<h2
class="font-bold text-base md:text-lg text-slate-800 flex items-center gap-2"
>
大野
<span
class="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-600 rounded md:inline-block hidden"
>在线</span
>
</h2>
<p class="text-xs text-gray-400 md:hidden">微信在线</p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- 算力胶囊 (Responsive) -->
<div
class="flex items-center gap-2 bg-gradient-to-r from-indigo-500 to-purple-600 text-white pl-3 pr-1 py-1 rounded-full shadow-md"
>
<div class="flex flex-col items-end leading-none">
<span class="text-[8px] opacity-80 uppercase tracking-wide"
>算力</span
>
<span class="text-xs font-mono font-bold">12,450</span>
</div>
<div
class="w-6 h-6 bg-white text-indigo-600 rounded-full flex items-center justify-center"
>
<i class="fa-solid fa-bolt text-xs"></i>
</div>
</div>
<!-- 详情开关 -->
<button
onclick="toggleRightDrawer()"
class="w-8 h-8 flex items-center justify-center text-slate-400 hover:text-primary transition"
>
<i class="fa-solid fa-circle-info text-lg"></i>
</button>
</div>
</header>
<!-- 聊天内容流 -->
<div
class="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 custom-scroll bg-[#F0F2F5] md:bg-[#F8FAFC]"
id="msgContainer"
>
<div class="text-center">
<span
class="text-[10px] text-gray-400 bg-gray-200/50 px-2 py-1 rounded"
>今天 10:23</span
>
</div>
<!-- 对方消息 -->
<div class="flex gap-3 items-start">
<img
src="https://i.pravatar.cc/150?u=8"
class="w-9 h-9 rounded-lg shadow-sm"
/>
<div class="flex flex-col gap-1 max-w-[80%]">
<span class="text-[10px] text-gray-400 ml-1">大野</span>
<div
class="bg-white border border-gray-100 px-4 py-2.5 rounded-2xl rounded-tl-none shadow-sm text-sm text-slate-700 leading-relaxed"
>
老板,上次说的那个魔兽代练套餐,如果是老客户的话,还能再送一次幻化吗?
</div>
</div>
</div>
<!-- AI 建议卡片 (样式适配) -->
<div
id="aiCard"
class="flex justify-end pr-0 md:pr-12 animate-fade-in-up"
>
<div
class="bg-gradient-to-b from-purple-50 to-white border border-purple-100 rounded-xl p-3 md:p-4 w-full md:w-[400px] shadow-lg relative"
>
<div class="flex justify-between items-center mb-2">
<div
class="flex items-center gap-2 text-purple-600 text-xs font-bold"
>
<i class="fa-solid fa-sparkles"></i> AI 建议回复
</div>
<span
class="text-[10px] text-orange-500 bg-orange-50 border border-orange-100 px-1.5 py-0.5 rounded"
>-5 算力</span
>
</div>
<p class="text-sm text-slate-600 mb-3 leading-relaxed">
“亲,没问题的!既然是老客户,这次直接帮您申请赠送幻化服务。现在下单的话,我这就去安排打手准备。”
</p>
<div class="flex justify-end gap-2">
<button
onclick="hideAiCard()"
class="px-3 py-1.5 text-xs text-gray-400 bg-gray-50 rounded-lg"
>
忽略
</button>
<button
onclick="sendAiMsg()"
class="px-4 py-1.5 bg-indigo-600 active:bg-indigo-700 text-white text-xs font-bold rounded-lg shadow-md flex items-center gap-2"
>
<i class="fa-solid fa-paper-plane"></i> 发送
</button>
</div>
</div>
</div>
</div>
<!-- 底部输入栏 -->
<div class="bg-white border-t border-gray-100 p-3 md:p-4 z-20 pb-safe">
<!-- 快捷工具行 -->
<div class="flex justify-between items-center mb-2 px-1">
<div class="flex gap-4 text-gray-400">
<i class="fa-regular fa-face-smile text-xl md:text-lg"></i>
<i class="fa-regular fa-image text-xl md:text-lg"></i>
<i
class="fa-solid fa-folder-open text-xl md:text-lg hidden md:block"
></i>
</div>
<!-- 移动端 AI 开关简化 -->
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-slate-500">AI</span>
<div
class="w-9 h-5 bg-indigo-600 rounded-full relative"
onclick="toggleSwitch(this)"
>
<div
class="w-4 h-4 bg-white rounded-full absolute top-0.5 right-0.5 shadow-sm transition-all"
></div>
</div>
</div>
</div>
<div class="flex gap-2 items-end">
<textarea
id="msgInput"
class="flex-1 bg-gray-100 md:bg-gray-50 border-none rounded-xl p-3 text-sm focus:ring-1 focus:ring-primary focus:bg-white resize-none h-10 md:h-20 transition-all custom-scroll"
placeholder="输入消息..."
></textarea>
<button
onclick="sendUserMsg()"
class="bg-slate-900 text-white h-10 w-16 md:w-auto md:px-6 rounded-xl text-sm font-bold flex items-center justify-center shrink-0"
>
发送
</button>
</div>
</div>
</main>
<!-- 4. 右侧抽屉 (CRM Drawer) -->
<!-- PC端根据屏幕宽度可常驻或折叠。平板/移动端:模态遮罩弹出 -->
<div
id="rightDrawerOverlay"
onclick="toggleRightDrawer()"
class="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 hidden md:hidden"
></div>
<aside
id="rightDrawer"
class="fixed right-0 top-0 h-full w-[85vw] md:w-80 bg-white shadow-2xl z-50 transform translate-x-full transition-transform duration-300 md:translate-x-full md:relative md:h-auto md:shadow-none md:border-l md:border-gray-200"
>
<div class="flex flex-col h-full">
<div
class="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50"
>
<h3 class="font-bold text-slate-800">客户资料</h3>
<button
onclick="toggleRightDrawer()"
class="w-8 h-8 bg-white rounded-full shadow-sm text-gray-500 flex items-center justify-center md:hidden"
>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto p-5 custom-scroll space-y-6">
<!-- 头像资料 -->
<div class="text-center">
<img
src="https://i.pravatar.cc/150?u=8"
class="w-20 h-20 rounded-full border-4 border-white shadow-lg mx-auto"
/>
<h3 class="mt-3 font-bold text-lg">大野</h3>
<p class="text-xs text-gray-400">ID: ys666wow</p>
</div>
<!-- 标签 -->
<div>
<h4 class="text-xs font-bold text-gray-400 uppercase mb-2">标签</h4>
<div class="flex flex-wrap gap-2">
<span
class="px-2 py-1 bg-red-50 text-red-600 text-xs font-bold rounded border border-red-100"
>高意向</span
>
<span
class="px-2 py-1 bg-blue-50 text-blue-600 text-xs font-bold rounded border border-blue-100"
>魔兽玩家</span
>
</div>
</div>
<!-- 资料卡 -->
<div class="bg-gray-50 rounded-xl p-4 space-y-3">
<div class="flex justify-between text-sm">
<span class="text-gray-500">备注</span>
<span class="font-medium text-slate-700">大野(朋友圈业务)</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">电话</span>
<span class="font-medium text-indigo-600">138-0000-8888</span>
</div>
</div>
</div>
</div>
</aside>
<!-- 5. 移动端底部导航 (Mobile Bottom Nav) -->
<nav
class="md:hidden fixed bottom-0 left-0 w-full bg-white border-t border-gray-200 flex justify-around items-center h-16 z-40 pb-safe shadow-[0_-4px_10px_rgba(0,0,0,0.02)]"
id="bottomNav"
>
<div
class="flex flex-col items-center gap-1 text-primary cursor-pointer w-16"
>
<i class="fa-solid fa-comment-dots text-xl"></i>
<span class="text-[10px] font-medium">消息</span>
</div>
<div
class="flex flex-col items-center gap-1 text-gray-400 cursor-pointer w-16 hover:text-slate-600"
>
<i class="fa-solid fa-address-book text-xl"></i>
<span class="text-[10px] font-medium">通讯录</span>
</div>
<div
class="flex flex-col items-center gap-1 text-gray-400 cursor-pointer w-16 hover:text-slate-600"
>
<i class="fa-solid fa-chart-pie text-xl"></i>
<span class="text-[10px] font-medium">数据</span>
</div>
<div
class="flex flex-col items-center gap-1 text-gray-400 cursor-pointer w-16 hover:text-slate-600"
>
<img
src="https://i.pravatar.cc/150?u=wx1"
class="w-6 h-6 rounded-full border border-gray-300"
/>
<span class="text-[10px] font-medium">我的</span>
</div>
</nav>
<!-- JS Logic -->
<script>
// 状态变量
let isChatOpen = false
let isDrawerOpen = false
// 1. 打开聊天窗口 (移动端:滑入 / PC切换内容)
function openChat(chatId) {
const chatWindow = document.getElementById('chatWindow')
const bottomNav = document.getElementById('bottomNav')
// 移动端逻辑
if (window.innerWidth < 768) {
chatWindow.classList.remove('translate-x-full')
chatWindow.classList.add('translate-x-0')
bottomNav.style.display = 'none' // 隐藏底部导航腾出空间
isChatOpen = true
} else {
// PC端逻辑高亮列表项这里简化处理
console.log('Switching chat content for ID:', chatId)
}
}
// 2. 关闭聊天窗口 (仅移动端有效)
function closeChat() {
const chatWindow = document.getElementById('chatWindow')
const bottomNav = document.getElementById('bottomNav')
chatWindow.classList.remove('translate-x-0')
chatWindow.classList.add('translate-x-full')
bottomNav.style.display = 'flex'
isChatOpen = false
}
// 3. 切换右侧详情抽屉
function toggleRightDrawer() {
const drawer = document.getElementById('rightDrawer')
const overlay = document.getElementById('rightDrawerOverlay')
if (isDrawerOpen) {
// Close
drawer.classList.remove('translate-x-0')
drawer.classList.add('translate-x-full')
if (window.innerWidth >= 768) {
// Desktop: Slide out completely or collapse width (depending on preference)
// Simple implementation: slide out
}
overlay.classList.add('hidden')
} else {
// Open
drawer.classList.remove('translate-x-full')
drawer.classList.add('translate-x-0')
if (window.innerWidth < 768) {
overlay.classList.remove('hidden')
}
}
isDrawerOpen = !isDrawerOpen
}
// 4. 发送消息逻辑
function sendUserMsg() {
const input = document.getElementById('msgInput')
const container = document.getElementById('msgContainer')
const text = input.value.trim()
if (!text) return
appendMsg(text, true, false)
input.value = ''
// 移动端输入后自动恢复高度
input.style.height = 'auto'
}
function sendAiMsg() {
const aiText =
'亲,没问题的!既然是老客户,这次直接帮您申请赠送幻化服务。现在下单的话,我这就去安排打手准备。'
appendMsg(aiText, true, true)
hideAiCard()
}
function appendMsg(text, isMe, isAi) {
const container = document.getElementById('msgContainer')
const html = `
<div class="flex gap-3 items-end ${isMe ? 'flex-row-reverse' : ''} animate-fade-in-up">
<img src="${isMe ? 'https://i.pravatar.cc/150?u=wx1' : 'https://i.pravatar.cc/150?u=8'}" class="w-8 h-8 rounded-lg shadow-sm">
<div class="flex flex-col gap-1 max-w-[80%] ${isMe ? 'items-end' : ''}">
${isAi ? '<div class="flex items-center gap-1 mb-0.5"><span class="text-[9px] bg-purple-50 text-purple-600 border border-purple-100 px-1 rounded">AI 生成</span></div>' : ''}
<div class="p-3 rounded-2xl text-sm leading-relaxed shadow-sm ${isMe ? 'bg-indigo-600 text-white rounded-br-none' : 'bg-white text-slate-700 border border-gray-100 rounded-bl-none'}">
${text}
</div>
</div>
</div>
`
container.insertAdjacentHTML('beforeend', html)
container.scrollTop = container.scrollHeight
}
function hideAiCard() {
document.getElementById('aiCard').style.display = 'none'
}
function toggleSwitch(el) {
const dot = el.querySelector('div')
if (el.classList.contains('bg-indigo-600')) {
el.classList.remove('bg-indigo-600')
el.classList.add('bg-gray-200')
dot.classList.remove('right-0.5')
dot.classList.add('left-0.5')
} else {
el.classList.add('bg-indigo-600')
el.classList.remove('bg-gray-200')
dot.classList.add('right-0.5')
dot.classList.remove('left-0.5')
}
}
// Add CSS Animation
const style = document.createElement('style')
style.innerHTML = `
@keyframes fadeInUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.animate-fade-in-up { animation: fadeInUp 0.3s ease-out forwards; }
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
`
document.head.appendChild(style)
// 监听 PC 端右侧抽屉常驻逻辑 (可选)
window.addEventListener('resize', () => {
if (window.innerWidth >= 768) {
document.getElementById('bottomNav').style.display = 'none'
document.getElementById('rightDrawerOverlay').classList.add('hidden')
// PC 端默认不显示遮罩
} else {
if (!isChatOpen)
document.getElementById('bottomNav').style.display = 'flex'
}
})
</script>
</body>
</html>

View File

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Some files were not shown because too many files have changed in this diff Show More