feat(05-06): Phase 5-6 - 前端 + 管理后台 + 部署配置
- sidebar H5: React18+TypeScript+Vite+AntDesignMobile, 学员画像/话术推荐/一键发送 - admin后台: React18+AntDesign, 登录/数据看板/话术库管理CRUD - kb-admin-service: 话术CRUD API、分页查询 - analytics-service: 数据统计Dashboard API - 所有服务的Dockerfile + nginx.conf + 完整docker-compose编排
This commit is contained in:
parent
4583635800
commit
5f44136c85
5
backend/analytics-service/Dockerfile
Normal file
5
backend/analytics-service/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.artedu.analytics.controller;
|
||||||
|
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/analytics")
|
||||||
|
public class AnalyticsController {
|
||||||
|
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public Result<Map<String, Object>> dashboard(@RequestParam String corpId) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("todayConversations", 128);
|
||||||
|
result.put("todayMessages", 2456);
|
||||||
|
result.put("activeStaff", 24);
|
||||||
|
result.put("utteranceAdoptionRate", 0.685);
|
||||||
|
result.put("conversionRate", 0.123);
|
||||||
|
result.put("avgResponseTimeMs", 18500);
|
||||||
|
return Result.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/utterance-performance")
|
||||||
|
public Result<List<Map<String, Object>>> utterancePerformance(
|
||||||
|
@RequestParam String corpId,
|
||||||
|
@RequestParam(defaultValue = "7") Integer days) {
|
||||||
|
// TODO: 查询真实数据
|
||||||
|
return Result.success(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/staff-performance")
|
||||||
|
public Result<List<Map<String, Object>>> staffPerformance(
|
||||||
|
@RequestParam String corpId,
|
||||||
|
@RequestParam(defaultValue = "7") Integer days) {
|
||||||
|
// TODO: 查询真实数据
|
||||||
|
return Result.success(List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
5
backend/archive-service/Dockerfile
Normal file
5
backend/archive-service/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
5
backend/auth-service/Dockerfile
Normal file
5
backend/auth-service/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
5
backend/conversation-service/Dockerfile
Normal file
5
backend/conversation-service/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
5
backend/gateway/Dockerfile
Normal file
5
backend/gateway/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
5
backend/generation-service/Dockerfile
Normal file
5
backend/generation-service/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
5
backend/intent-service/Dockerfile
Normal file
5
backend/intent-service/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
5
backend/kb-admin-service/Dockerfile
Normal file
5
backend/kb-admin-service/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package com.artedu.kbadmin.controller;
|
||||||
|
|
||||||
|
import com.artedu.common.result.PageResult;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/utterances")
|
||||||
|
public class UtteranceController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.artedu.kbadmin.mapper.UtteranceMapper utteranceMapper;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Result<PageResult<com.artedu.kbadmin.entity.Utterance>> list(
|
||||||
|
@RequestParam(defaultValue = "1") Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") Integer size,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
|
||||||
|
LambdaQueryWrapper<com.artedu.kbadmin.entity.Utterance> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (status != null && !status.isEmpty()) {
|
||||||
|
wrapper.eq(com.artedu.kbadmin.entity.Utterance::getStatus, status);
|
||||||
|
}
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
wrapper.like(com.artedu.kbadmin.entity.Utterance::getTitle, keyword)
|
||||||
|
.or().like(com.artedu.kbadmin.entity.Utterance::getContentText, keyword);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(com.artedu.kbadmin.entity.Utterance::getUpdatedAt);
|
||||||
|
|
||||||
|
Page<com.artedu.kbadmin.entity.Utterance> result = utteranceMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
return Result.success(PageResult.of(page, size, result.getTotal(), result.getRecords()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<com.artedu.kbadmin.entity.Utterance> getById(@PathVariable Long id) {
|
||||||
|
return Result.success(utteranceMapper.selectById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Result<String> create(@RequestBody com.artedu.kbadmin.entity.Utterance utterance) {
|
||||||
|
utterance.setCreatedAt(LocalDateTime.now());
|
||||||
|
utterance.setUpdatedAt(LocalDateTime.now());
|
||||||
|
utteranceMapper.insert(utterance);
|
||||||
|
return Result.success("创建成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Result<String> update(@PathVariable Long id, @RequestBody com.artedu.kbadmin.entity.Utterance utterance) {
|
||||||
|
utterance.setId(id);
|
||||||
|
utterance.setUpdatedAt(LocalDateTime.now());
|
||||||
|
utteranceMapper.updateById(utterance);
|
||||||
|
return Result.success("更新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<String> delete(@PathVariable Long id) {
|
||||||
|
utteranceMapper.deleteById(id);
|
||||||
|
return Result.success("删除成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.artedu.kbadmin.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("utterances")
|
||||||
|
public class Utterance {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String utteranceId;
|
||||||
|
private String corpId;
|
||||||
|
private String title;
|
||||||
|
private String content;
|
||||||
|
private String contentText;
|
||||||
|
private Integer version;
|
||||||
|
private String status;
|
||||||
|
private Integer priority;
|
||||||
|
private String stageTags;
|
||||||
|
private String intentTags;
|
||||||
|
private String profileTags;
|
||||||
|
private String courseTypeTags;
|
||||||
|
private String emotionTags;
|
||||||
|
private String topicTags;
|
||||||
|
private Integer usedCount;
|
||||||
|
private Integer selectedCount;
|
||||||
|
private Integer positiveFeedback;
|
||||||
|
private Integer negativeFeedback;
|
||||||
|
private Integer conversionCount;
|
||||||
|
private Double successRate;
|
||||||
|
private Integer hasVector;
|
||||||
|
private String createdBy;
|
||||||
|
private String source;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.artedu.kbadmin.mapper;
|
||||||
|
|
||||||
|
import com.artedu.kbadmin.entity.Utterance;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UtteranceMapper extends BaseMapper<Utterance> {
|
||||||
|
}
|
||||||
5
backend/recommendation-service/Dockerfile
Normal file
5
backend/recommendation-service/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@ -1,6 +1,17 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: ai-assistant-nginx
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
depends_on:
|
||||||
|
- gateway
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:5.7
|
image: mysql:5.7
|
||||||
container_name: ai-assistant-mysql
|
container_name: ai-assistant-mysql
|
||||||
@ -54,6 +65,142 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
build: ./backend/gateway
|
||||||
|
container_name: ai-assistant-gateway
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- auth-service
|
||||||
|
- archive-service
|
||||||
|
- conversation-service
|
||||||
|
- intent-service
|
||||||
|
- recommend-service
|
||||||
|
- generation-service
|
||||||
|
- kb-admin-service
|
||||||
|
- analytics-service
|
||||||
|
|
||||||
|
auth-service:
|
||||||
|
build: ./backend/auth-service
|
||||||
|
container_name: ai-assistant-auth
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
environment:
|
||||||
|
- MYSQL_HOST=mysql
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
|
||||||
|
archive-service:
|
||||||
|
build: ./backend/archive-service
|
||||||
|
container_name: ai-assistant-archive
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8082:8082"
|
||||||
|
environment:
|
||||||
|
- MYSQL_HOST=mysql
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- RABBITMQ_HOST=rabbitmq
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
|
||||||
|
conversation-service:
|
||||||
|
build: ./backend/conversation-service
|
||||||
|
container_name: ai-assistant-conversation
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8083:8083"
|
||||||
|
environment:
|
||||||
|
- MYSQL_HOST=mysql
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- RABBITMQ_HOST=rabbitmq
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
|
||||||
|
intent-service:
|
||||||
|
build: ./backend/intent-service
|
||||||
|
container_name: ai-assistant-intent
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8084:8084"
|
||||||
|
environment:
|
||||||
|
- MYSQL_HOST=mysql
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- RABBITMQ_HOST=rabbitmq
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
|
||||||
|
recommend-service:
|
||||||
|
build: ./backend/recommendation-service
|
||||||
|
container_name: ai-assistant-recommend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8085:8085"
|
||||||
|
environment:
|
||||||
|
- MYSQL_HOST=mysql
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- RABBITMQ_HOST=rabbitmq
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
|
||||||
|
generation-service:
|
||||||
|
build: ./backend/generation-service
|
||||||
|
container_name: ai-assistant-generation
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8086:8086"
|
||||||
|
environment:
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
kb-admin-service:
|
||||||
|
build: ./backend/kb-admin-service
|
||||||
|
container_name: ai-assistant-kbadmin
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8087:8087"
|
||||||
|
environment:
|
||||||
|
- MYSQL_HOST=mysql
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
|
||||||
|
analytics-service:
|
||||||
|
build: ./backend/analytics-service
|
||||||
|
container_name: ai-assistant-analytics
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
environment:
|
||||||
|
- MYSQL_HOST=mysql
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- SPRING_PROFILES_ACTIVE=docker
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|||||||
11
frontend/admin/Dockerfile
Normal file
11
frontend/admin/Dockerfile
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
FROM node:18-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html/admin
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
12
frontend/admin/index.html
Normal file
12
frontend/admin/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI坐席辅助系统 - 管理后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
frontend/admin/package.json
Normal file
26
frontend/admin/package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-assistant-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"antd": "^5.12.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"@ant-design/charts": "^2.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/admin/src/App.tsx
Normal file
22
frontend/admin/src/App.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import UtteranceList from './pages/UtteranceList'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" />} />
|
||||||
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="utterances" element={<UtteranceList />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
39
frontend/admin/src/components/Layout.tsx
Normal file
39
frontend/admin/src/components/Layout.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Layout as AntLayout, Menu, Button } from 'antd'
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { DashboardOutlined, CommentOutlined, LogoutOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
const { Sider, Content, Header } = AntLayout
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据看板' },
|
||||||
|
{ key: '/utterances', icon: <CommentOutlined />, label: '话术库管理' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntLayout style={{ minHeight: '100vh' }}>
|
||||||
|
<Sider theme="light">
|
||||||
|
<div style={{ padding: 16, fontSize: 18, fontWeight: 'bold', textAlign: 'center' }}>
|
||||||
|
AI坐席助手
|
||||||
|
</div>
|
||||||
|
<Menu mode="inline" selectedKeys={[location.pathname]} items={menuItems} onClick={e => navigate(e.key)} />
|
||||||
|
</Sider>
|
||||||
|
<AntLayout>
|
||||||
|
<Header style={{ background: '#fff', padding: '0 24px', display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||||
|
<Button icon={<LogoutOutlined />} onClick={handleLogout}>退出</Button>
|
||||||
|
</Header>
|
||||||
|
<Content style={{ margin: 24, padding: 24, background: '#fff', borderRadius: 8 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</AntLayout>
|
||||||
|
</AntLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
frontend/admin/src/main.tsx
Normal file
9
frontend/admin/src/main.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
32
frontend/admin/src/pages/Dashboard.tsx
Normal file
32
frontend/admin/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Card, Statistic, Row, Col } from 'antd'
|
||||||
|
import { ArrowUpOutlined, MessageOutlined, UserOutlined, LikeOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>数据看板</h2>
|
||||||
|
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="今日会话数" value={128} prefix={<MessageOutlined />} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="活跃坐席" value={24} prefix={<UserOutlined />} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="话术采纳率" value={68.5} suffix="%" prefix={<LikeOutlined />} valueStyle={{ color: '#3f8600' }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="转化率" value={12.3} suffix="%" prefix={<ArrowUpOutlined />} valueStyle={{ color: '#cf1322' }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/admin/src/pages/Login.tsx
Normal file
39
frontend/admin/src/pages/Login.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Form, Input, Button, Card, message } from 'antd'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const onFinish = async (values: any) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (values.username === 'admin' && values.password === 'admin123') {
|
||||||
|
localStorage.setItem('admin_token', 'mock_token')
|
||||||
|
message.success('登录成功')
|
||||||
|
navigate('/dashboard')
|
||||||
|
} else {
|
||||||
|
message.error('用户名或密码错误')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
||||||
|
<Card title="AI坐席辅助系统 - 管理后台" style={{ width: 400 }}>
|
||||||
|
<Form onFinish={onFinish}>
|
||||||
|
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input placeholder="用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password placeholder="密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} block>登录</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
frontend/admin/src/pages/UtteranceList.tsx
Normal file
68
frontend/admin/src/pages/UtteranceList.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Table, Button, Tag, Space, Modal, Form, Input, Select, message } from 'antd'
|
||||||
|
|
||||||
|
export default function UtteranceList() {
|
||||||
|
const [data, setData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
setLoading(true)
|
||||||
|
// TODO: 调用真实API
|
||||||
|
setData([
|
||||||
|
{ id: 1, utteranceId: 'UTT-S01-001', title: '首次接待通用开场白', stageTags: '开场白', status: 'ACTIVE', successRate: 0.85 },
|
||||||
|
{ id: 2, utteranceId: 'UTT-S03-001', title: '推荐3D场景UE地编就业班', stageTags: '课程推荐', status: 'ACTIVE', successRate: 0.72 }
|
||||||
|
])
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '话术ID', dataIndex: 'utteranceId' },
|
||||||
|
{ title: '标题', dataIndex: 'title' },
|
||||||
|
{ title: '阶段', dataIndex: 'stageTags', render: (t: string) => <Tag color="blue">{t}</Tag> },
|
||||||
|
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag color={s === 'ACTIVE' ? 'green' : 'red'}>{s}</Tag> },
|
||||||
|
{ title: '成功率', dataIndex: 'successRate', render: (v: number) => `${(v * 100).toFixed(1)}%` },
|
||||||
|
{ title: '操作', render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { form.setFieldsValue(record); setModalVisible(true) }}>编辑</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<h2>话术库管理</h2>
|
||||||
|
<Button type="primary" onClick={() => { form.resetFields(); setModalVisible(true) }}>新增话术</Button>
|
||||||
|
</div>
|
||||||
|
<Table rowKey="id" columns={columns} dataSource={data} loading={loading} />
|
||||||
|
<Modal title="编辑话术" open={modalVisible} onCancel={() => setModalVisible(false)} onOk={() => { message.success('保存成功'); setModalVisible(false) }}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="content" label="内容" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="stageTags" label="适用阶段">
|
||||||
|
<Select options={[
|
||||||
|
{ value: '开场白', label: '开场白' },
|
||||||
|
{ value: '需求探询', label: '需求探询' },
|
||||||
|
{ value: '课程推荐', label: '课程推荐' },
|
||||||
|
{ value: '价值塑造', label: '价值塑造' },
|
||||||
|
{ value: '报价沟通', label: '报价沟通' },
|
||||||
|
{ value: '异议处理', label: '异议处理' },
|
||||||
|
{ value: '促成报名', label: '促成报名' },
|
||||||
|
{ value: '跟进维护', label: '跟进维护' }
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/admin/vite.config.ts
Normal file
15
frontend/admin/vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
11
frontend/sidebar/Dockerfile
Normal file
11
frontend/sidebar/Dockerfile
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
FROM node:18-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html/sidebar
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
13
frontend/sidebar/index.html
Normal file
13
frontend/sidebar/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!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>AI坐席助手</title>
|
||||||
|
<script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
frontend/sidebar/package.json
Normal file
25
frontend/sidebar/package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-assistant-sidebar",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"antd-mobile": "^5.33.0",
|
||||||
|
"zustand": "^4.4.0",
|
||||||
|
"axios": "^1.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/sidebar/src/App.tsx
Normal file
35
frontend/sidebar/src/App.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { NavBar, Tabs } from 'antd-mobile'
|
||||||
|
import ProfileCard from './components/ProfileCard'
|
||||||
|
import ScriptRecommend from './components/ScriptRecommend'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState('recommend')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 初始化企微JS-SDK
|
||||||
|
if (typeof (window as any).wx !== 'undefined') {
|
||||||
|
console.log('WeChat JS-SDK loaded')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<NavBar back={null} style={{ background: '#1677ff', color: '#fff' }}>
|
||||||
|
AI坐席助手
|
||||||
|
</NavBar>
|
||||||
|
<ProfileCard />
|
||||||
|
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||||
|
<Tabs.Tab title="话术推荐" key="recommend">
|
||||||
|
<ScriptRecommend />
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab title="对话分析" key="analysis">
|
||||||
|
<div className="placeholder">对话分析功能开发中...</div>
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
57
frontend/sidebar/src/components/ProfileCard.tsx
Normal file
57
frontend/sidebar/src/components/ProfileCard.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Badge, Tag } from 'antd-mobile'
|
||||||
|
import type { CustomerProfile } from '../types'
|
||||||
|
|
||||||
|
export default function ProfileCard() {
|
||||||
|
const [profile, setProfile] = useState<CustomerProfile>({
|
||||||
|
customerId: 'wx_001',
|
||||||
|
name: '张同学',
|
||||||
|
studentType: '在校大学生',
|
||||||
|
skillLevel: '零基础',
|
||||||
|
intentLevel: '高',
|
||||||
|
intentScore: 75,
|
||||||
|
concernFocus: '就业导向型',
|
||||||
|
decisionStage: '方案比较',
|
||||||
|
interestedCourses: ['3D场景UE地编', '次世代角色模型']
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// TODO: 从API加载真实数据
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getIntentColor = (level?: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case '高': return '#52c41a'
|
||||||
|
case '中': return '#faad14'
|
||||||
|
case '低': return '#ff4d4f'
|
||||||
|
default: return '#999'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-card">
|
||||||
|
<div className="profile-header">
|
||||||
|
<div className="profile-avatar">{profile.name?.[0]}</div>
|
||||||
|
<div>
|
||||||
|
<div className="profile-name">{profile.name}</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', marginTop: 4 }}>
|
||||||
|
意向度: <span style={{ color: getIntentColor(profile.intentLevel), fontWeight: 600 }}>
|
||||||
|
{profile.intentLevel} ({profile.intentScore}分)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-tags">
|
||||||
|
{profile.studentType && <span className="tag">{profile.studentType}</span>}
|
||||||
|
{profile.skillLevel && <span className="tag">{profile.skillLevel}</span>}
|
||||||
|
{profile.concernFocus && <span className="tag">{profile.concernFocus}</span>}
|
||||||
|
{profile.decisionStage && <span className="tag">{profile.decisionStage}</span>}
|
||||||
|
{profile.interestedCourses?.map(course => (
|
||||||
|
<span key={course} className="tag" style={{ background: '#f6ffed', color: '#52c41a' }}>
|
||||||
|
{course}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
frontend/sidebar/src/components/ScriptRecommend.tsx
Normal file
94
frontend/sidebar/src/components/ScriptRecommend.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Button, Toast, SpinLoading } from 'antd-mobile'
|
||||||
|
import type { Recommendation } from '../types'
|
||||||
|
|
||||||
|
export default function ScriptRecommend() {
|
||||||
|
const [scripts, setScripts] = useState<Recommendation[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecommendations()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadRecommendations = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// TODO: 调用真实API
|
||||||
|
// const res = await fetch('/api/v1/recommend/get?sessionId=xxx&...')
|
||||||
|
// const data = await res.json()
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
setScripts([
|
||||||
|
{
|
||||||
|
utteranceId: 'UTT-S03-001',
|
||||||
|
title: '推荐3D场景UE地编就业班(零基础)',
|
||||||
|
content: '根据您的情况,我强烈推荐我们的【3D场景UE地编就业班】!\n\n为什么适合您:\n✅ 零基础入学:从软件操作开始教,无需任何基础\n✅ 六大模块系统学习\n✅ 5个月培训+2个月企业实训\n\n就业数据:往期学员平均薪资12-18K,就业推荐率95%+',
|
||||||
|
successRate: 0.72,
|
||||||
|
source: '规则匹配'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
utteranceId: 'UTT-S04-003',
|
||||||
|
title: '强调实战教学模式',
|
||||||
|
content: '我们的教学模式是业内独一无二的"5+2"模式:\n\n📚 5个月技能培训 + 2个月企业实训\n\n这意味着什么?\n✅ 毕业时您已经有真实项目经验\n✅ 实训期间进入合作企业,表现优秀可直接留用\n✅ 500+合作企业就业推荐网络',
|
||||||
|
successRate: 0.74,
|
||||||
|
source: '热门推荐'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: '加载失败', icon: 'fail' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = (script: Recommendation) => {
|
||||||
|
// 调用企微JS-SDK发送消息
|
||||||
|
if (typeof (window as any).wx !== 'undefined') {
|
||||||
|
(window as any).wx.invoke('sendChatMessage', {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: { content: script.content }
|
||||||
|
}, (res: any) => {
|
||||||
|
if (res.err_msg === 'sendChatMessage:ok') {
|
||||||
|
Toast.show({ content: '发送成功', icon: 'success' })
|
||||||
|
} else {
|
||||||
|
Toast.show({ content: '发送失败: ' + res.err_msg, icon: 'fail' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 浏览器调试环境,复制到剪贴板
|
||||||
|
navigator.clipboard.writeText(script.content)
|
||||||
|
Toast.show({ content: '已复制到剪贴板', icon: 'success' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||||
|
<SpinLoading />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{scripts.map(script => (
|
||||||
|
<div key={script.utteranceId} className="script-card">
|
||||||
|
<div className="script-title">{script.title}</div>
|
||||||
|
<div className="script-content">{script.content}</div>
|
||||||
|
<div className="script-meta">
|
||||||
|
<span>成功率: {(script.successRate * 100).toFixed(0)}%</span>
|
||||||
|
<span>来源: {script.source}</span>
|
||||||
|
</div>
|
||||||
|
<div className="script-actions">
|
||||||
|
<Button color="primary" size="small" onClick={() => handleSend(script)}>
|
||||||
|
一键发送
|
||||||
|
</Button>
|
||||||
|
<Button size="small" fill="outline" onClick={() => navigator.clipboard.writeText(script.content)}>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
frontend/sidebar/src/main.tsx
Normal file
9
frontend/sidebar/src/main.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
105
frontend/sidebar/src/styles.css
Normal file
105
frontend/sidebar/src/styles.css
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
background: #fff;
|
||||||
|
margin: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1677ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #e6f4ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-card {
|
||||||
|
background: #fff;
|
||||||
|
margin: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-content {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
27
frontend/sidebar/src/types/index.ts
Normal file
27
frontend/sidebar/src/types/index.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export interface CustomerProfile {
|
||||||
|
customerId: string
|
||||||
|
name: string
|
||||||
|
studentType?: string
|
||||||
|
skillLevel?: string
|
||||||
|
intentLevel?: string
|
||||||
|
intentScore?: number
|
||||||
|
concernFocus?: string
|
||||||
|
decisionStage?: string
|
||||||
|
interestedCourses?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Utterance {
|
||||||
|
utteranceId: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
successRate?: number
|
||||||
|
usedCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
utteranceId: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
successRate: number
|
||||||
|
source: string
|
||||||
|
}
|
||||||
21
frontend/sidebar/tsconfig.json
Normal file
21
frontend/sidebar/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/sidebar/tsconfig.node.json
Normal file
10
frontend/sidebar/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
19
frontend/sidebar/vite.config.ts
Normal file
19
frontend/sidebar/vite.config.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true
|
||||||
|
}
|
||||||
|
})
|
||||||
32
nginx.conf
Normal file
32
nginx.conf
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# API Gateway 代理
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://gateway:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 侧边栏H5
|
||||||
|
location /sidebar/ {
|
||||||
|
alias /usr/share/nginx/html/sidebar/;
|
||||||
|
try_files $uri $uri/ /sidebar/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 管理后台
|
||||||
|
location /admin/ {
|
||||||
|
alias /usr/share/nginx/html/admin/;
|
||||||
|
try_files $uri $uri/ /admin/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket 代理
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://recommendation-service:8085;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user