feat: 实现 KPL 系统功能改进计划
Some checks failed
continuous-integration/drone/push Build is failing

1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

2. 路由权限检查完善
   - 新增前端 permissionChecker.ts 权限检查工具
   - 更新 router/guard.ts 实现团队和课程权限验证
   - 新增后端 permission_service.py

3. AI 陪练音频转文本
   - 新增 speech_recognition.py 语音识别服务
   - 新增 /api/v1/speech/* API
   - 更新 ai-practice-coze.vue 支持语音输入

4. 双人对练报告生成
   - 更新 practice_room_service.py 添加报告生成功能
   - 新增 /rooms/{room_code}/report API
   - 更新 duo-practice-report.vue 调用真实 API

5. 学习提醒推送
   - 新增 notification_service.py 通知服务
   - 新增 scheduler_service.py 定时任务服务
   - 支持钉钉、企微、站内消息推送

6. 智能学习推荐
   - 新增 recommendation_service.py 推荐服务
   - 新增 /api/v1/recommendations/* API
   - 支持错题、能力、进度、热门多维度推荐

7. 安全问题修复
   - DEBUG 默认值改为 False
   - 添加 SECRET_KEY 安全警告
   - 新增 check_security_settings() 检查函数

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
This commit is contained in:
yuliang_guo
2026-01-30 14:22:35 +08:00
parent 9793013a56
commit 64f5d567fa
66 changed files with 18067 additions and 14330 deletions

View File

@@ -183,6 +183,11 @@ import {
type CozeSession,
type StreamEvent
} from '@/api/coze'
import {
SpeechRecognitionManager,
isSpeechRecognitionSupported,
type SpeechRecognitionResult
} from '@/utils/speechRecognition'
const router = useRouter()
@@ -205,6 +210,11 @@ const voiceStatusText = ref('点击开始按钮进行语音陪练')
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
// 语音识别相关
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
const recognizedText = ref('')
const isSpeechSupported = isSpeechRecognitionSupported()
// DOM引用
const messageContainer = ref<HTMLElement>()
@@ -380,9 +390,21 @@ const toggleRecording = async () => {
}
/**
* 开始录音
* 开始录音(同时启动语音识别)
*/
const startRecording = async () => {
if (!cozeSession.value) {
ElMessage.warning('请先开始陪练会话')
return
}
// 优先使用 Web Speech API 进行实时语音识别
if (isSpeechSupported) {
startSpeechRecognition()
return
}
// 降级到录音模式(需要后端语音识别服务)
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
@@ -400,7 +422,7 @@ const startRecording = async () => {
mediaRecorder.value.start()
isRecording.value = true
voiceStatusText.value = '正在录音...'
voiceStatusText.value = '正在录音(浏览器不支持实时识别,录音结束后将发送到服务器识别)...'
} catch (error) {
ElMessage.error('无法访问麦克风')
}
@@ -410,6 +432,13 @@ const startRecording = async () => {
* 停止录音
*/
const stopRecording = () => {
// 如果使用的是 Web Speech API
if (speechRecognition.value) {
stopSpeechRecognition()
return
}
// 如果使用的是录音模式
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop()
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
@@ -420,13 +449,116 @@ const stopRecording = () => {
}
/**
* 处理录音
* 处理录音(使用 Web Speech API 已识别的文本)
*/
const processAudio = async (_audioBlob: Blob) => {
// TODO: 实现音频转文本和发送逻辑
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
ElMessage.info('语音功能正在开发中')
try {
// 检查是否有识别结果
const text = recognizedText.value.trim()
if (!text) {
ElMessage.warning('没有检测到语音内容')
return
}
// 清空识别结果
recognizedText.value = ''
// 发送识别的文本消息
if (cozeSession.value) {
// 添加用户消息
messages.value.push({
role: 'user',
content: text,
timestamp: new Date()
})
await scrollToBottom()
isLoading.value = true
// 创建AI回复消息占位
const assistantMessage = {
role: 'assistant',
content: '',
timestamp: new Date()
}
messages.value.push(assistantMessage)
// 流式发送消息
await sendCozeMessage(
cozeSession.value.sessionId,
text,
(event: StreamEvent) => {
if (event.type === 'message.delta') {
assistantMessage.content += event.content
scrollToBottom()
} else if (event.type === 'message.completed') {
isLoading.value = false
}
}
)
}
} catch (error: any) {
ElMessage.error('发送消息失败:' + (error.message || '未知错误'))
} finally {
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
}
}
/**
* 开始语音识别
*/
const startSpeechRecognition = () => {
if (!isSpeechSupported) {
ElMessage.warning('您的浏览器不支持语音识别,请使用 Chrome 或 Edge 浏览器')
return
}
// 创建语音识别管理器
speechRecognition.value = new SpeechRecognitionManager({
continuous: true,
interimResults: true,
lang: 'zh-CN'
})
speechRecognition.value.setCallbacks({
onResult: (result: SpeechRecognitionResult) => {
recognizedText.value = result.transcript
voiceStatusText.value = result.isFinal
? `识别结果: ${result.transcript}`
: `正在识别: ${result.transcript}`
},
onError: (error: string) => {
ElMessage.error(error)
stopSpeechRecognition()
},
onStart: () => {
isRecording.value = true
voiceStatusText.value = '正在监听,请说话...'
},
onEnd: () => {
// 识别结束后自动处理
if (recognizedText.value.trim()) {
processAudio(new Blob())
} else {
isRecording.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
}
}
})
speechRecognition.value.start()
}
/**
* 停止语音识别
*/
const stopSpeechRecognition = () => {
if (speechRecognition.value) {
speechRecognition.value.stop()
speechRecognition.value = null
}
isRecording.value = false
}
/**
@@ -456,6 +588,10 @@ onUnmounted(() => {
if (mediaRecorder.value && isRecording.value) {
stopRecording()
}
if (speechRecognition.value) {
speechRecognition.value.destroy()
speechRecognition.value = null
}
})
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,401 +1,401 @@
<template>
<div class="duo-practice-page">
<div class="page-header">
<h1>双人对练</h1>
<p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p>
</div>
<div class="main-content">
<!-- 创建/加入房间卡片 -->
<div class="action-cards">
<!-- 创建房间 -->
<div class="action-card create-card" @click="showCreateDialog = true">
<div class="card-icon">
<el-icon :size="48"><Plus /></el-icon>
</div>
<h3>创建房间</h3>
<p>创建对练房间邀请伙伴加入</p>
</div>
<!-- 加入房间 -->
<div class="action-card join-card" @click="showJoinDialog = true">
<div class="card-icon">
<el-icon :size="48"><Connection /></el-icon>
</div>
<h3>加入房间</h3>
<p>输入房间码加入对练</p>
</div>
</div>
<!-- 我的房间列表 -->
<div class="my-rooms" v-if="myRooms.length > 0">
<h2>我的对练记录</h2>
<div class="room-list">
<div
v-for="room in myRooms"
:key="room.id"
class="room-item"
@click="enterRoom(room)"
>
<div class="room-info">
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
<span class="room-code">{{ room.room_code }}</span>
</div>
<div class="room-meta">
<el-tag :type="getStatusType(room.status)" size="small">
{{ getStatusText(room.status) }}
</el-tag>
<span class="room-time">{{ formatTime(room.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 创建房间对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建对练房间"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" label-width="100px">
<el-form-item label="场景名称">
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
</el-form-item>
<el-form-item label="角色A名称">
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="角色B名称">
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
</el-form-item>
<el-form-item label="我扮演">
<el-radio-group v-model="createForm.host_role">
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="场景背景">
<el-input
v-model="createForm.scene_background"
type="textarea"
:rows="3"
placeholder="描述对练场景的背景信息(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
创建房间
</el-button>
</template>
</el-dialog>
<!-- 加入房间对话框 -->
<el-dialog
v-model="showJoinDialog"
title="加入对练房间"
width="400px"
:close-on-click-modal="false"
>
<el-form>
<el-form-item label="房间码">
<el-input
v-model="joinRoomCode"
placeholder="请输入6位房间码"
maxlength="6"
style="font-size: 24px; text-align: center; letter-spacing: 8px;"
@keyup.enter="handleJoinRoom"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showJoinDialog = false">取消</el-button>
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
加入房间
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Connection } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
const router = useRouter()
const store = useDuoPracticeStore()
// 状态
const showCreateDialog = ref(false)
const showJoinDialog = ref(false)
const isCreating = ref(false)
const isJoining = ref(false)
const joinRoomCode = ref('')
const myRooms = ref<RoomListItem[]>([])
// 创建表单
const createForm = ref({
scene_name: '',
role_a_name: '销售顾问',
role_b_name: '顾客',
host_role: 'A' as 'A' | 'B',
scene_background: ''
})
// 加载我的房间列表
const loadMyRooms = async () => {
try {
const res: any = await getMyRooms()
if (res.code === 200) {
myRooms.value = res.data.rooms
}
} catch (error) {
console.error('加载房间列表失败:', error)
}
}
// 创建房间
const handleCreateRoom = async () => {
if (!createForm.value.scene_name) {
ElMessage.warning('请输入场景名称')
return
}
isCreating.value = true
try {
await store.createRoom(createForm.value)
showCreateDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isCreating.value = false
}
}
// 加入房间
const handleJoinRoom = async () => {
if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
ElMessage.warning('请输入6位房间码')
return
}
isJoining.value = true
try {
await store.joinRoom(joinRoomCode.value)
showJoinDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isJoining.value = false
}
}
// 进入房间
const enterRoom = (room: RoomListItem) => {
router.push(`/trainee/duo-practice/room/${room.room_code}`)
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'waiting': 'warning',
'ready': 'info',
'practicing': 'success',
'completed': '',
'canceled': 'danger'
}
return map[status] || ''
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'waiting': '等待加入',
'ready': '准备就绪',
'practicing': '对练中',
'completed': '已完成',
'canceled': '已取消'
}
return map[status] || status
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return ''
const date = new Date(time)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadMyRooms()
})
</script>
<style scoped lang="scss">
.duo-practice-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 16px;
}
}
.action-cards {
display: flex;
gap: 24px;
justify-content: center;
margin-bottom: 48px;
.action-card {
width: 280px;
padding: 40px 24px;
background: #fff;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
&.create-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
p {
color: rgba(255, 255, 255, 0.8);
}
}
&.join-card {
border-color: #667eea;
.card-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:hover {
border-color: #764ba2;
}
}
.card-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
}
}
}
.my-rooms {
h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.room-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.room-item {
background: #fff;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.room-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.room-name {
font-weight: 500;
font-size: 16px;
}
.room-code {
font-family: monospace;
font-size: 14px;
color: #667eea;
background: #f0f2ff;
padding: 2px 8px;
border-radius: 4px;
}
}
.room-meta {
display: flex;
justify-content: space-between;
align-items: center;
.room-time {
font-size: 12px;
color: #999;
}
}
}
}
</style>
<template>
<div class="duo-practice-page">
<div class="page-header">
<h1>双人对练</h1>
<p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p>
</div>
<div class="main-content">
<!-- 创建/加入房间卡片 -->
<div class="action-cards">
<!-- 创建房间 -->
<div class="action-card create-card" @click="showCreateDialog = true">
<div class="card-icon">
<el-icon :size="48"><Plus /></el-icon>
</div>
<h3>创建房间</h3>
<p>创建对练房间邀请伙伴加入</p>
</div>
<!-- 加入房间 -->
<div class="action-card join-card" @click="showJoinDialog = true">
<div class="card-icon">
<el-icon :size="48"><Connection /></el-icon>
</div>
<h3>加入房间</h3>
<p>输入房间码加入对练</p>
</div>
</div>
<!-- 我的房间列表 -->
<div class="my-rooms" v-if="myRooms.length > 0">
<h2>我的对练记录</h2>
<div class="room-list">
<div
v-for="room in myRooms"
:key="room.id"
class="room-item"
@click="enterRoom(room)"
>
<div class="room-info">
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
<span class="room-code">{{ room.room_code }}</span>
</div>
<div class="room-meta">
<el-tag :type="getStatusType(room.status)" size="small">
{{ getStatusText(room.status) }}
</el-tag>
<span class="room-time">{{ formatTime(room.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 创建房间对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建对练房间"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" label-width="100px">
<el-form-item label="场景名称">
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
</el-form-item>
<el-form-item label="角色A名称">
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="角色B名称">
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
</el-form-item>
<el-form-item label="我扮演">
<el-radio-group v-model="createForm.host_role">
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="场景背景">
<el-input
v-model="createForm.scene_background"
type="textarea"
:rows="3"
placeholder="描述对练场景的背景信息(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
创建房间
</el-button>
</template>
</el-dialog>
<!-- 加入房间对话框 -->
<el-dialog
v-model="showJoinDialog"
title="加入对练房间"
width="400px"
:close-on-click-modal="false"
>
<el-form>
<el-form-item label="房间码">
<el-input
v-model="joinRoomCode"
placeholder="请输入6位房间码"
maxlength="6"
style="font-size: 24px; text-align: center; letter-spacing: 8px;"
@keyup.enter="handleJoinRoom"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showJoinDialog = false">取消</el-button>
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
加入房间
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Connection } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
const router = useRouter()
const store = useDuoPracticeStore()
// 状态
const showCreateDialog = ref(false)
const showJoinDialog = ref(false)
const isCreating = ref(false)
const isJoining = ref(false)
const joinRoomCode = ref('')
const myRooms = ref<RoomListItem[]>([])
// 创建表单
const createForm = ref({
scene_name: '',
role_a_name: '销售顾问',
role_b_name: '顾客',
host_role: 'A' as 'A' | 'B',
scene_background: ''
})
// 加载我的房间列表
const loadMyRooms = async () => {
try {
const res: any = await getMyRooms()
if (res.code === 200) {
myRooms.value = res.data.rooms
}
} catch (error) {
console.error('加载房间列表失败:', error)
}
}
// 创建房间
const handleCreateRoom = async () => {
if (!createForm.value.scene_name) {
ElMessage.warning('请输入场景名称')
return
}
isCreating.value = true
try {
await store.createRoom(createForm.value)
showCreateDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isCreating.value = false
}
}
// 加入房间
const handleJoinRoom = async () => {
if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
ElMessage.warning('请输入6位房间码')
return
}
isJoining.value = true
try {
await store.joinRoom(joinRoomCode.value)
showJoinDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isJoining.value = false
}
}
// 进入房间
const enterRoom = (room: RoomListItem) => {
router.push(`/trainee/duo-practice/room/${room.room_code}`)
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'waiting': 'warning',
'ready': 'info',
'practicing': 'success',
'completed': '',
'canceled': 'danger'
}
return map[status] || ''
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'waiting': '等待加入',
'ready': '准备就绪',
'practicing': '对练中',
'completed': '已完成',
'canceled': '已取消'
}
return map[status] || status
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return ''
const date = new Date(time)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadMyRooms()
})
</script>
<style scoped lang="scss">
.duo-practice-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 16px;
}
}
.action-cards {
display: flex;
gap: 24px;
justify-content: center;
margin-bottom: 48px;
.action-card {
width: 280px;
padding: 40px 24px;
background: #fff;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
&.create-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
p {
color: rgba(255, 255, 255, 0.8);
}
}
&.join-card {
border-color: #667eea;
.card-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:hover {
border-color: #764ba2;
}
}
.card-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
}
}
}
.my-rooms {
h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.room-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.room-item {
background: #fff;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.room-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.room-name {
font-weight: 500;
font-size: 16px;
}
.room-code {
font-family: monospace;
font-size: 14px;
color: #667eea;
background: #f0f2ff;
padding: 2px 8px;
border-radius: 4px;
}
}
.room-meta {
display: flex;
justify-content: space-between;
align-items: center;
.room-time {
font-size: 12px;
color: #999;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff