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:
jiao 2026-05-12 18:12:20 +08:00
parent 4583635800
commit 5f44136c85
37 changed files with 1090 additions and 0 deletions

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -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());
}
}

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -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("删除成功");
}
}

View File

@ -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;
}

View File

@ -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> {
}

View File

@ -0,0 +1,5 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -1,6 +1,17 @@
version: '3.8'
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:
image: mysql:5.7
container_name: ai-assistant-mysql
@ -54,6 +65,142 @@ services:
timeout: 5s
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:
mysql_data:
redis_data:

11
frontend/admin/Dockerfile Normal file
View 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
View 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>

View 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"
}
}

View 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

View 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>
)
}

View 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>
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}
}
}
})

View 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

View 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>

View 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"
}
}

View 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

View 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>
)
}

View 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>
)
}

View 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>
)

View 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;
}

View 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
}

View 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" }]
}

View File

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

View 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
View 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";
}
}