提交 e535338e authored 作者: 王鹏飞's avatar 王鹏飞

feat: 合并 school 本地化适配,统一文件/视频上传并接入大模型配置

上级 b0691436
VITE_LOGIN_URL=https://login.ezijing.com/auth/login/index VITE_LOGIN_URL=https://login.ezijing.com/auth/login/index
VITE_QA_CENTER_URL=https://qa-center.ezijing.com VITE_QA_CENTER_URL=https://qa-center.ezijing.com
VITE_BI_URL=https://bi.ezijing.com VITE_BI_URL=https://bi.ezijing.com
VITE_STATIC_URL=https://saas-lab-api
VITE_LOGIN_URL=http://172.16.3.203:1001/auth/login/index
VITE_QA_CENTER_URL=http://172.16.3.203:1004
VITE_BI_URL=http://172.16.3.203:1012
VITE_FILE_PREVIEW_URL=http://172.16.3.203:8012
VITE_STATIC_URL=https://saas-lab-api
\ No newline at end of file
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="https://webapp-pub.ezijing.com/website/base/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>统一资源管理平台</title> <title>统一资源管理平台</title>
<script src="/center_resource/lib/tinymce@6/tinymce.min.js"></script> <script src="/center_resource/lib/tinymce@6/tinymce.min.js"></script>
......
差异被折叠。
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "vite --mode dev", "dev": "vite --mode dev",
"build": "vue-tsc --noEmit && vite build --mode prod && npm run deploy", "build": "vue-tsc --noEmit && vite build --mode prod && npm run deploy",
"build:test": "vue-tsc --noEmit && vite build --mode test", "build:school": "vue-tsc --noEmit && vite build --mode school",
"build:pre": "vue-tsc --noEmit && vite build --mode pre", "build:pre": "vue-tsc --noEmit && vite build --mode pre",
"preview": "vite preview --port 4173", "preview": "vite preview --port 4173",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
...@@ -49,8 +49,7 @@ ...@@ -49,8 +49,7 @@
"sass": "^1.70.0", "sass": "^1.70.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.0.0",
"vite": "^8.0.1", "vite": "^8.0.2",
"vite-plugin-checker": "^0.12.0",
"vite-plugin-mkcert": "^1.17.10", "vite-plugin-mkcert": "^1.17.10",
"vue-tsc": "^3.2.6" "vue-tsc": "^3.2.6"
} }
......
...@@ -25,7 +25,7 @@ export function uploadFile(data: Record<string, any>) { ...@@ -25,7 +25,7 @@ export function uploadFile(data: Record<string, any>) {
return httpRequest return httpRequest
.post(data.host || 'https://webapp-pub.ezijing.com', data, { .post(data.host || 'https://webapp-pub.ezijing.com', data, {
withCredentials: false, withCredentials: false,
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' },
}) })
.then(() => data) .then(() => data)
} }
...@@ -62,3 +62,38 @@ export function getProjectList(params: { organization_id?: string; project_id?: ...@@ -62,3 +62,38 @@ export function getProjectList(params: { organization_id?: string; project_id?:
export function getQuestionCategory(params: { project_tag: string }) { export function getQuestionCategory(params: { project_tag: string }) {
return httpRequest.get(`/api/qbs/admin/v2/question-category/tree/${params.project_tag}`, { params }) return httpRequest.get(`/api/qbs/admin/v2/question-category/tree/${params.project_tag}`, { params })
} }
// 获取分片大小和唯一文件名
export function getLocalFileChunk(params: { file_size: number; file_name: string }) {
return httpRequest.get('/api/lab/v1/common/file/chunk', { params })
}
// 上传每个分片前请求接口来获取当前文件是否超时,之前的分片是否被清理,如果被请求则拒绝处理。返回客户端错误码,让客户端户端不再续传剩余分片
// 1文件被清理 0文件未被清理
export function checkLocalFile(params: { file_name: string }) {
return httpRequest.get('/api/lab/v1/common/file/check', { params })
}
// 上传文件
export function uploadLocalFile(
data: {
file: File | Blob
file_name: string
is_continuingly?: number
now_package_num: number
total_package_num: number
},
options = {},
) {
return httpRequest.post(
'/api/lab/v1/common/file/upload',
data,
Object.assign(
{
withCredentials: false,
headers: { 'Content-Type': 'multipart/form-data' },
},
options,
),
)
}
...@@ -20,7 +20,8 @@ import { ...@@ -20,7 +20,8 @@ import {
QuestionFilled, QuestionFilled,
EditPen, EditPen,
DataAnalysis, DataAnalysis,
ChatDotRound ChatDotRound,
Setting,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
export const menus: IMenuItem[] = [ export const menus: IMenuItem[] = [
{ {
...@@ -32,37 +33,37 @@ export const menus: IMenuItem[] = [ ...@@ -32,37 +33,37 @@ export const menus: IMenuItem[] = [
tag: 'v1-resource-video-list', tag: 'v1-resource-video-list',
icon: VideoCamera, icon: VideoCamera,
name: '视频', name: '视频',
path: '/resource/video' path: '/resource/video',
}, },
{ {
tag: 'v1-resource-courseware-list', tag: 'v1-resource-courseware-list',
icon: Suitcase, icon: Suitcase,
name: '课件', name: '课件',
path: '/resource/courseware' path: '/resource/courseware',
}, },
{ {
tag: 'v1-resource-lesson-plan-list', tag: 'v1-resource-lesson-plan-list',
icon: FolderOpened, icon: FolderOpened,
name: '教案', name: '教案',
path: '/resource/lessonplan' path: '/resource/lessonplan',
}, },
{ {
tag: 'v1-resource-other-information-list', tag: 'v1-resource-other-information-list',
icon: Files, icon: Files,
name: '其他资料', name: '其他资料',
path: '/resource/other' path: '/resource/other',
}, },
{ {
icon: Collection, icon: Collection,
name: '题库管理', name: '题库管理',
path: import.meta.env.VITE_QA_CENTER_URL + '/question/list?project_tag=resourse_ci' path: import.meta.env.VITE_QA_CENTER_URL + '/question/list?project_tag=resourse_ci',
}, },
{ {
icon: ToiletPaper, icon: ToiletPaper,
name: '试卷管理', name: '试卷管理',
path: import.meta.env.VITE_QA_CENTER_URL + '/paper/list?project_tag=resourse_ci' path: import.meta.env.VITE_QA_CENTER_URL + '/paper/list?project_tag=resourse_ci',
} },
] ],
}, },
{ {
tag: 'v1-course', tag: 'v1-course',
...@@ -73,15 +74,15 @@ export const menus: IMenuItem[] = [ ...@@ -73,15 +74,15 @@ export const menus: IMenuItem[] = [
tag: 'v1-course-list', tag: 'v1-course-list',
icon: Monitor, icon: Monitor,
name: '我的课程', name: '我的课程',
path: '/course/my' path: '/course/my',
}, },
{ {
tag: 'v1-course-create', tag: 'v1-course-create',
icon: Edit, icon: Edit,
name: '新建课程', name: '新建课程',
path: '/course/update-course' path: '/course/update-course',
} },
] ],
}, },
{ {
tag: 'v1-learning', tag: 'v1-learning',
...@@ -92,51 +93,51 @@ export const menus: IMenuItem[] = [ ...@@ -92,51 +93,51 @@ export const menus: IMenuItem[] = [
tag: 'v1-backend-lecturer-list', tag: 'v1-backend-lecturer-list',
icon: UserFilled, icon: UserFilled,
name: '讲师管理', name: '讲师管理',
path: '/admin/teacher' path: '/admin/teacher',
}, },
{ {
tag: 'v1-learning-teacher-list', tag: 'v1-learning-teacher-list',
icon: School, icon: School,
name: '教工用户管理', name: '教工用户管理',
path: '/admin/staff' path: '/admin/staff',
}, },
{ {
tag: 'v1-learning-student-list', tag: 'v1-learning-student-list',
icon: User, icon: User,
name: '学生管理', name: '学生管理',
path: '/admin/student' path: '/admin/student',
}, },
{ {
tag: 'v1-backend-specialty-list', tag: 'v1-backend-specialty-list',
icon: Promotion, icon: Promotion,
name: '专业管理', name: '专业管理',
path: '/admin/pro' path: '/admin/pro',
}, },
{ {
tag: 'v1-learning-class-list', tag: 'v1-learning-class-list',
icon: School, icon: School,
name: '班级管理', name: '班级管理',
path: '/admin/class' path: '/admin/class',
}, },
{ {
tag: 'v1-learning-semester-list', tag: 'v1-learning-semester-list',
icon: Guide, icon: Guide,
name: '学期管理', name: '学期管理',
path: '/admin/semester' path: '/admin/semester',
}, },
{ {
tag: 'v1-backend-category-list', tag: 'v1-backend-category-list',
icon: Filter, icon: Filter,
name: '类别管理', name: '类别管理',
path: '/admin/category' path: '/admin/category',
}, },
{ {
icon: Coordinate, icon: Coordinate,
name: '资源审核管理', name: '资源审核管理',
path: '/admin/audit' path: '/admin/audit',
} },
] ],
}, },
{ {
tag: 'v1-backend', tag: 'v1-backend',
...@@ -147,21 +148,30 @@ export const menus: IMenuItem[] = [ ...@@ -147,21 +148,30 @@ export const menus: IMenuItem[] = [
tag: 'v1-backend-data-dictionary-list', tag: 'v1-backend-data-dictionary-list',
icon: Notebook, icon: Notebook,
name: '数据字典', name: '数据字典',
path: '/system/dictionary' path: '/system/dictionary',
}, },
{ {
tag: 'v1-backend-cover-list', tag: 'v1-backend-cover-list',
icon: Picture, icon: Picture,
name: '封面管理', name: '封面管理',
path: '/system/cover' path: '/system/cover',
}, },
{ {
tag: 'v1-backend-suggestion-list', tag: 'v1-backend-suggestion-list',
icon: ChatDotRound, icon: ChatDotRound,
name: ' 投诉建议管理', name: ' 投诉建议管理',
path: '/system/suggestion' path: '/system/suggestion',
} },
] ...(import.meta.env.MODE === 'school'
? [
{
icon: Setting,
name: '大模型配置',
path: '/system/llm',
},
]
: []),
],
}, },
{ {
tag: 'v1-teaching', tag: 'v1-teaching',
...@@ -172,44 +182,53 @@ export const menus: IMenuItem[] = [ ...@@ -172,44 +182,53 @@ export const menus: IMenuItem[] = [
tag: 'v1-teaching-discussion', tag: 'v1-teaching-discussion',
icon: QuestionFilled, icon: QuestionFilled,
name: '帖子管理', name: '帖子管理',
path: '/teach/posts' path: '/teach/posts',
}, },
{ {
tag: 'v1-teaching-paper-paper-list', tag: 'v1-teaching-paper-paper-list',
icon: EditPen, icon: EditPen,
name: '批改试卷', name: '批改试卷',
path: '/teach/exam' path: '/teach/exam',
}, },
{ {
tag: 'v1-teaching-job-list', tag: 'v1-teaching-job-list',
icon: Edit, icon: Edit,
name: '批改大作业', name: '批改大作业',
path: '/teach/work' path: '/teach/work',
}, },
{ ...(import.meta.env.MODE === 'school'
tag: '', ? [
icon: DataAnalysis, {
name: '课程资源数据画像', icon: DataAnalysis,
path: name: '课程资源数据画像',
import.meta.env.VITE_BI_URL + // path:
'/bi/?proc=1&action=viewer&hback=true&isInPreview=true&db=!7d2b!!8346!!6559!!80b2!e-SaaS!2f!!8d44!!6e90!!6570!!636e!!753b!!50cf!.db&platform=PC&browserType=chrome' // import.meta.env.VITE_BI_URL +
}, // '/bi/?proc=1&action=viewer&hback=true&isInPreview=true&db=!7d2b!!8346!!6559!!80b2!e-SaaS!2f!!8d44!!6e90!!6570!!636e!!753b!!50cf!.db&platform=PC&browserType=chrome',
{ path: '/teach/chart/resource',
tag: '', },
icon: DataAnalysis, {
name: '在线学习数据画像', icon: DataAnalysis,
path: name: '在线学习数据画像',
import.meta.env.VITE_BI_URL + // path:
'/bi/?proc=1&action=viewer&hback=true&isInPreview=true&db=!7d2b!!8346!!6559!!80b2!e-SaaS!2f!!5b66!!4e60!!884c!!4e3a!!753b!!50cf!.db&platform=PC&browserType=chrome' // import.meta.env.VITE_BI_URL +
}, // '/bi/?proc=1&action=viewer&hback=true&isInPreview=true&db=!7d2b!!8346!!6559!!80b2!e-SaaS!2f!!5b66!!4e60!!884c!!4e3a!!753b!!50cf!.db&platform=PC&browserType=chrome',
{ path: '/teach/chart/learning',
tag: '', },
icon: DataAnalysis, {
name: '生源地分布', icon: DataAnalysis,
path: name: '数据可视化',
import.meta.env.VITE_BI_URL + path: '/teach/chart/visualization',
'/bi/?proc=1&action=viewer&hback=true&isInPreview=true&db=!7d2b!!8346!!6559!!80b2!e-SaaS!2f!!751f!!6e90!!5730!!5206!!5e03!.db&platform=PC&browserType=chrome' },
} {
] icon: DataAnalysis,
} name: '生源地分布',
// path:
// import.meta.env.VITE_BI_URL +
// '/bi/?proc=1&action=viewer&hback=true&isInPreview=true&db=!7d2b!!8346!!6559!!80b2!e-SaaS!2f!!751f!!6e90!!5730!!5206!!5e03!.db&platform=PC&browserType=chrome',
path: '/teach/chart/origin',
},
]
: []),
],
},
] ]
<script lang="ts" setup> <script lang="ts" setup>
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import type { UploadProps, UploadUserFile } from 'element-plus' import type { UploadProps, UploadRequestOptions, UploadUserFile } from 'element-plus'
import md5 from 'blueimp-md5' import { upload } from '@/utils/upload'
import { getSignature } from '@/api/base'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
...@@ -20,8 +19,6 @@ const props = withDefaults( ...@@ -20,8 +19,6 @@ const props = withDefaults(
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const uploadData = ref()
const fileList = ref<UploadUserFile[]>([]) const fileList = ref<UploadUserFile[]>([])
watch( watch(
...@@ -35,23 +32,23 @@ const showFileList = computed(() => { ...@@ -35,23 +32,23 @@ const showFileList = computed(() => {
return Array.isArray(props.modelValue) return Array.isArray(props.modelValue)
}) })
// 上传之前
const handleBeforeUpload = async (file: any) => { const handleBeforeUpload = async (file: any) => {
// Keep a pseudo-url for legacy validators that inspect file.url/file name before upload.
file.url = file.name
const fileName = file.name const fileName = file.name
const key = props.prefix + md5(fileName + new Date().getTime()) + '/' + fileName
const response: Record<string, any> = await getSignature()
uploadData.value = {
key,
host: response.host,
OSSAccessKeyId: response.accessid,
policy: response.policy,
signature: response.signature,
success_action_status: '200',
url: `${response.host}/${key}`
}
file.url = `${response.host}/${key}`
if (props.beforeUploadFiles) { if (props.beforeUploadFiles) {
return props.beforeUploadFiles(file) return props.beforeUploadFiles({ ...file, url: fileName })
}
}
const handleUploadRequest = async (option: UploadRequestOptions) => {
try {
const url = await upload(option.file)
;(option.file as any).url = url
;(option.file as any).raw = Object.assign((option.file as any).raw || {}, { url })
option.onSuccess({ url })
} catch (error: any) {
option.onError(error)
} }
} }
...@@ -73,6 +70,7 @@ const handleSuccess: UploadProps['onSuccess'] = (response, file: any, files: any ...@@ -73,6 +70,7 @@ const handleSuccess: UploadProps['onSuccess'] = (response, file: any, files: any
} else { } else {
emit('update:modelValue', file.raw.url) emit('update:modelValue', file.raw.url)
} }
props.onChange?.(file, files)
} }
// 上传限制 // 上传限制
...@@ -102,8 +100,8 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => { ...@@ -102,8 +100,8 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => {
<template> <template>
<el-upload <el-upload
:action="uploadData?.host" action="#"
:data="uploadData" :http-request="handleUploadRequest"
:show-file-list="showFileList" :show-file-list="showFileList"
:before-upload="handleBeforeUpload" :before-upload="handleBeforeUpload"
:on-exceed="handleExceed" :on-exceed="handleExceed"
......
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps({ import { computed } from 'vue'
item: { import { getOnlinePreviewUrl } from '@/utils/util'
type: Object,
require: true interface PreviewFileItem {
url?: string
name?: string
}
const props = defineProps<{
item?: PreviewFileItem
url?: string
name?: string
}>()
const previewItem = computed<PreviewFileItem>(() => {
return {
url: props.item?.url || props.url || '',
name: props.item?.name || props.name || '',
} }
}) })
let isShowType = ref(1) const fileUrl = computed(() => previewItem.value.url || '')
// 判断用什么标签展示内容 const fileName = computed(() => previewItem.value.name || '')
if (props.item?.url?.indexOf('.pdf') !== -1 || props.item?.url?.indexOf('.txt') !== -1) {
isShowType.value = 2 const fileExtension = computed(() => {
} else if (props.item?.url?.indexOf('.mp4') !== -1) { const cleanUrl = fileUrl.value.split('?')[0].split('#')[0].toLowerCase()
isShowType.value = 3 const matches = cleanUrl.match(/\.([a-z0-9]+)$/)
} else if (props.item?.url?.indexOf('.mp3') !== -1) { return matches?.[1] || ''
isShowType.value = 4 })
} else if (
props.item?.url?.indexOf('.png') !== -1 || const previewType = computed(() => {
props.item?.url?.indexOf('.jpg') !== -1 || if (['pdf', 'txt'].includes(fileExtension.value)) return 'embed'
props.item?.url?.indexOf('.jpeg') !== -1 if (fileExtension.value === 'mp4') return 'video'
) { if (fileExtension.value === 'mp3') return 'audio'
isShowType.value = 5 if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(fileExtension.value)) return 'image'
} else if (props.item?.url?.indexOf('.rar') !== -1 || props.item?.url?.indexOf('.zip') !== -1) { if (['rar', 'zip'].includes(fileExtension.value)) return 'archive'
isShowType.value = 6 return 'iframe'
} })
const archiveIcon = computed(() => {
if (fileExtension.value === 'rar') return '/center_resource/rar.png'
if (fileExtension.value === 'zip') return '/center_resource/zip.png'
return ''
})
</script> </script>
<template> <template>
<el-card> <el-card>
<div class="max-w-h"> <div class="preview-container">
<iframe <iframe v-if="previewType === 'iframe'" :src="getOnlinePreviewUrl(fileUrl)" allowfullscreen>
id="iframe" {{ fileName }}
v-if="isShowType === 1" </iframe>
:src="`https://view.officeapps.live.com/op/view.aspx?src=${props.item?.url}`"
>{{ item?.name }}</iframe <embed v-else-if="previewType === 'embed'" :src="fileUrl" />
>
<embed :src="props.item?.url" v-else-if="isShowType === 2" /> <video v-else-if="previewType === 'video'" controls>
<video v-else-if="isShowType === 3" controls id="video"> <source :src="fileUrl" />
<source :src="props.item?.url" />
</video> </video>
<audio v-else-if="isShowType === 4" :src="props.item?.url" controls></audio>
<img v-else-if="isShowType === 5" :src="props.item?.url" />
<div v-else-if="isShowType === 6" class="zip_con">
<a :href="props.item?.url" style="color: #aa1941">
<img v-if="props.item?.url?.indexOf('.rar') !== -1" src="/center_resource/rar.png" class="img_zip" />
<img v-else-if="props.item?.url?.indexOf('.zip') !== -1" src="/center_resource/zip.png" class="img_zip" />
{{ props.item?.name }}
</a>
<div class="zip_tips">* 该文件格式暂不支持预览,可点击上方文件名下载</div> <audio v-else-if="previewType === 'audio'" :src="fileUrl" controls />
<img v-else-if="previewType === 'image'" :src="fileUrl" :alt="fileName" />
<div v-else class="archive-container">
<a :href="fileUrl" class="archive-link">
<img v-if="archiveIcon" :src="archiveIcon" class="archive-icon" />
{{ fileName || fileUrl }}
</a>
<div class="archive-tip">* 该文件格式暂不支持预览,可点击上方文件名下载</div>
</div> </div>
</div> </div>
</el-card> </el-card>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.max-w-h { .preview-container {
// max-width: 1200px;
width: 100%; width: 100%;
height: 600px; height: 600px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: stretch;
min-height: 0;
iframe, iframe,
embed, embed,
video { video,
img {
flex: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
}
.zip_con {
border: 1px solid #ccc;
width: 100%;
display: block; display: block;
display: flex; border: 0;
justify-content: center;
align-items: center;
flex-direction: column;
}
.img_zip {
width: 100px;
height: 100px;
margin: auto;
}
.zip_tips {
font-size: 14px;
margin-top: 10px;
} }
img { img {
width: 100%;
display: block; display: block;
object-fit: contain;
} }
} }
.archive-container {
width: 100%;
border: 1px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.archive-link {
color: #aa1941;
display: flex;
flex-direction: column;
align-items: center;
}
.archive-icon {
width: 100px;
height: 100px;
margin: auto;
}
.archive-tip {
font-size: 14px;
margin-top: 10px;
}
</style> </style>
<script lang="ts"> <script lang="ts">
export default { export default {
name: 'AppAside' name: 'AppAside',
} }
</script> </script>
...@@ -10,10 +10,20 @@ import type { IMenuItem } from '@/types' ...@@ -10,10 +10,20 @@ import type { IMenuItem } from '@/types'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
function isUrl(path: string) {
return /^https?:\/\//.test(path)
}
function isRouteMatch(currentPath: string, targetPath: string) {
if (!targetPath || isUrl(targetPath)) return false
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
}
const menuList = computed<IMenuItem[]>(() => { const menuList = computed<IMenuItem[]>(() => {
const found = menus.find(item => route.fullPath.includes(item.path)) const found = menus.find((item) => isRouteMatch(route.path, item.path))
return found?.children || [] return found?.children || []
}) })
const defaultActive = computed(() => { const defaultActive = computed(() => {
// 扁平菜单 // 扁平菜单
const flatMenuList: IMenuItem[] = menuList.value.reduce((result: IMenuItem[], item) => { const flatMenuList: IMenuItem[] = menuList.value.reduce((result: IMenuItem[], item) => {
...@@ -23,16 +33,12 @@ const defaultActive = computed(() => { ...@@ -23,16 +33,12 @@ const defaultActive = computed(() => {
} }
return result return result
}, []) }, [])
const found = flatMenuList.reverse().find(item => { const found = flatMenuList.reverse().find((item) => {
return route.path.includes(item.path) return isRouteMatch(route.path, item.path)
}) })
return found ? found.path : '/' return found ? found.path : '/'
}) })
function isUrl(path: string) {
return /^https?:\/\//.test(path)
}
function handleClick(path: string) { function handleClick(path: string) {
if (isUrl(path)) { if (isUrl(path)) {
window.open(path) window.open(path)
...@@ -56,8 +62,7 @@ function handleClick(path: string) { ...@@ -56,8 +62,7 @@ function handleClick(path: string) {
v-for="subitem in item.children" v-for="subitem in item.children"
:key="subitem.path" :key="subitem.path"
v-permission="subitem.tag" v-permission="subitem.tag"
@click="handleClick(subitem.path)" @click="handleClick(subitem.path)">
>
{{ subitem.name }} {{ subitem.name }}
</el-menu-item> </el-menu-item>
</el-sub-menu> </el-sub-menu>
......
...@@ -7,7 +7,7 @@ import { menus } from '@/assets/menus' ...@@ -7,7 +7,7 @@ import { menus } from '@/assets/menus'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import type { IMenuItem } from '@/types' import type { IMenuItem } from '@/types'
withDefaults(defineProps<{ hasTitle?: boolean }>(), { withDefaults(defineProps<{ hasTitle?: boolean }>(), {
hasTitle: true hasTitle: true,
}) })
const route = useRoute() const route = useRoute()
...@@ -31,7 +31,7 @@ function genNavClassName(data: IMenuItem) { ...@@ -31,7 +31,7 @@ function genNavClassName(data: IMenuItem) {
<header class="app-header"> <header class="app-header">
<div class="app-header-left"> <div class="app-header-left">
<div class="logo"> <div class="logo">
<router-link to="/"><img src="https://webapp-pub.ezijing.com/website/base/logo_white.svg" /></router-link> <router-link to="/"><img src="/logo_white.svg" /></router-link>
</div> </div>
<h1 class="app-name">统一资源管理平台</h1> <h1 class="app-name">统一资源管理平台</h1>
</div> </div>
...@@ -48,7 +48,7 @@ function genNavClassName(data: IMenuItem) { ...@@ -48,7 +48,7 @@ function genNavClassName(data: IMenuItem) {
<div class="app-header-right"> <div class="app-header-right">
<el-dropdown v-if="userInfo"> <el-dropdown v-if="userInfo">
<div class="avatar"> <div class="avatar">
<img :src="userInfo.avatar || 'https://webapp-pub.ezijing.com/website/base/avatar.png'" /> <img :src="userInfo.avatar || '/avatar.png'" />
</div> </div>
<template #dropdown> <template #dropdown>
<el-dropdown-menu style="width: 280px"> <el-dropdown-menu style="width: 280px">
...@@ -161,7 +161,10 @@ function genNavClassName(data: IMenuItem) { ...@@ -161,7 +161,10 @@ function genNavClassName(data: IMenuItem) {
.app-header-user-main { .app-header-user-main {
h3 { h3 {
color: #202124; color: #202124;
font: 500 16px/22px Helvetica, Arial, sans-serif; font:
500 16px/22px Helvetica,
Arial,
sans-serif;
letter-spacing: 0.29px; letter-spacing: 0.29px;
margin: 0; margin: 0;
text-align: center; text-align: center;
...@@ -170,7 +173,10 @@ function genNavClassName(data: IMenuItem) { ...@@ -170,7 +173,10 @@ function genNavClassName(data: IMenuItem) {
} }
p { p {
color: #5f6368; color: #5f6368;
font: 400 14px/19px Helvetica, Arial, sans-serif; font:
400 14px/19px Helvetica,
Arial,
sans-serif;
letter-spacing: normal; letter-spacing: normal;
text-align: center; text-align: center;
text-overflow: ellipsis; text-overflow: ellipsis;
......
<script setup lang="ts"> <script setup lang="ts">
import Editor from '@tinymce/tinymce-vue' import Editor from '@tinymce/tinymce-vue'
import md5 from 'blueimp-md5' import { upload } from '@/utils/upload'
import { getSignature, uploadFile } from '@/api/base'
const props = defineProps({ const props = defineProps({
height: { height: {
...@@ -12,32 +11,12 @@ const props = defineProps({ ...@@ -12,32 +11,12 @@ const props = defineProps({
const ImageUploadHandler = (blobInfo: any) => const ImageUploadHandler = (blobInfo: any) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const file = blobInfo.blob() upload(blobInfo.blob())
getSignature() .then((url) => {
.then((response: any) => { resolve(url)
const prefix = 'upload/admin/'
const key = prefix + md5(file.name + new Date().getTime()) + file.name.substr(file.name.lastIndexOf('.'))
const { accessid, policy, signature, host } = response
const params = {
key,
host,
OSSAccessKeyId: accessid,
policy,
signature,
success_action_status: '200',
file,
url: `${host}/${key}`,
}
uploadFile(params)
.then((res: any) => {
resolve(res.url)
})
.catch(() => {
reject('上传失败')
})
}) })
.catch(() => { .catch(() => {
reject('获取Signature失败') reject('上传失败')
}) })
}) })
......
import { getLocalFileChunk, uploadLocalFile } from '@/api/base'
interface FileItem {
file: File
url: string
name: string
progress: number
abortController: AbortController
}
interface UploadOptions {
multiple?: boolean
autoUpload?: boolean
onUploadStarted?: (file: FileItem) => void
onUploadSucceed?: (file: FileItem) => void
onUploadFailed?: (file: FileItem, error: any) => void
onUploadProgress?: (file: FileItem, progress: number) => void
onUploadEnd?: () => void
}
export function useUpload(options: UploadOptions = {}) {
options = Object.assign({ autoUpload: true, multiple: false }, options)
const files = ref<FileItem[]>([])
const uploading = ref(false)
function addFile(file: File) {
// 检查文件是否已经在队列中
const existingFileIndex = files.value.findIndex((f) => f.name === file.name)
// 如果文件已经在队列中,并且上传已经开始,则直接返回
if (existingFileIndex !== -1 && files.value[existingFileIndex].progress > 0) return
if (!options.multiple) stopUpload()
const abortController = new AbortController()
files.value.push({ url: '', name: file.name, file, progress: 0, abortController })
if (options.autoUpload && !uploading.value) startUpload()
}
async function startUpload() {
uploading.value = true
for (const file of files.value) {
if (file.progress === 0) {
// 只处理尚未开始上传的文件
try {
options?.onUploadStarted?.(file)
await uploadFile(file)
options?.onUploadSucceed?.(file)
} catch (error) {
options?.onUploadFailed?.(file, error)
}
}
}
uploading.value = false
options?.onUploadEnd?.()
}
async function uploadFile(file: FileItem) {
const {
data: { detail },
} = await getLocalFileChunk({ file_name: file.file.name, file_size: file.file.size })
const fileName = detail.file_name
const chunkSize = detail.chunk_size
const totalChunks = Math.ceil(file.file.size / chunkSize)
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const chunk = file.file.slice(chunkIndex * chunkSize, (chunkIndex + 1) * chunkSize)
await uploadChunk({ file, chunk, chunkIndex, fileName, totalChunks })
}
}
async function uploadChunk({
file,
chunk,
chunkIndex,
fileName,
totalChunks,
}: {
file: FileItem
chunk: Blob
chunkIndex: number
fileName: string
totalChunks: number
}) {
const {
data: { detail },
} = await uploadLocalFile(
{ file_name: fileName, file: chunk, now_package_num: chunkIndex + 1, total_package_num: totalChunks },
{
signal: file.abortController.signal,
onUploadProgress(event: ProgressEvent) {
updateProgress({ file, event, chunkIndex, totalChunks })
},
},
)
file.url = detail.uri
}
function updateProgress({
event,
chunkIndex,
totalChunks,
file,
}: {
file: FileItem
event: ProgressEvent
chunkIndex: number
totalChunks: number
}) {
const totalSize = event.total * totalChunks
const loadedSize = event.loaded + chunkIndex * event.total
const progressPercent = (loadedSize / totalSize) * 100
file.progress = parseFloat(progressPercent.toFixed(2))
options?.onUploadProgress?.(file, file.progress)
}
function cancelUpload(file: FileItem) {
file.abortController.abort()
files.value = files.value.filter((item) => item.name !== file.name)
}
function stopUpload() {
files.value.forEach(cancelUpload) // 停止所有文件的上传
files.value.length = 0
uploading.value = false // 更新上传状态
}
return { files, uploading, addFile, startUpload, cancelUpload, stopUpload }
}
...@@ -185,11 +185,14 @@ const handleSelectionChange = (val: any) => { ...@@ -185,11 +185,14 @@ const handleSelectionChange = (val: any) => {
} }
const handleAnalysis = () => { const handleAnalysis = () => {
// isShowAnalysisDialog.value = true // isShowAnalysisDialog.value = true
window.open( // window.open(
import.meta.env.VITE_BI_URL + // import.meta.env.VITE_BI_URL +
'/bi/?proc=1&action=viewer&hback=true&isInPreview=true&db=!7d2b!!8346!!6559!!80b2!e-SaaS!2f!!751f!!6e90!!5730!!5206!!5e03!.db&platform=PC&browserType=chrome', // '/bi/?proc=1&action=viewer&hback=true&isInPreview=true&db=!7d2b!!8346!!6559!!80b2!e-SaaS!2f!!751f!!6e90!!5730!!5206!!5e03!.db&platform=PC&browserType=chrome',
) // )
window.open('/teach/chart/origin')
} }
const isSchool = import.meta.env.MODE === 'school'
</script> </script>
<template> <template>
...@@ -213,7 +216,7 @@ const handleAnalysis = () => { ...@@ -213,7 +216,7 @@ const handleAnalysis = () => {
<el-button type="primary" round @click="handleUpdate" v-permission="'v1-learning-student-import'" <el-button type="primary" round @click="handleUpdate" v-permission="'v1-learning-student-import'"
>批量修改</el-button >批量修改</el-button
> >
<el-button type="primary" round @click="handleAnalysis">生源地分析</el-button> <el-button type="primary" round @click="handleAnalysis" v-if="isSchool">生源地分析</el-button>
<template #status="{ row }"> <template #status="{ row }">
<el-switch <el-switch
size="large" size="large"
......
...@@ -2,6 +2,7 @@ import axios from 'axios' ...@@ -2,6 +2,7 @@ import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useGetCategoryList } from '@/composables/useGetCategoryList' import { useGetCategoryList } from '@/composables/useGetCategoryList'
import { upload } from '@/utils/upload' import { upload } from '@/utils/upload'
import { getUploadedVideoSourceId, uploadVideo as uploadVideoFile } from '@/utils/videoUpload'
import { import {
createBook, createBook,
createCase, createCase,
...@@ -10,8 +11,6 @@ import { ...@@ -10,8 +11,6 @@ import {
createResourceOther, createResourceOther,
createResourceVideo, createResourceVideo,
createVideo, createVideo,
getResourceUploadVideoAuth,
updateResourceUploadVideoAuth,
} from '../api' } from '../api'
import type { AuthorizePlatform, CaseFileSelectionType, CaseLibraryFile, CaseLibraryItem } from '../types' import type { AuthorizePlatform, CaseFileSelectionType, CaseLibraryFile, CaseLibraryItem } from '../types'
import { useCaseLibrary } from './useCaseLibrary' import { useCaseLibrary } from './useCaseLibrary'
...@@ -265,55 +264,21 @@ export function useCaseAuthorization() { ...@@ -265,55 +264,21 @@ export function useCaseAuthorization() {
total: number, total: number,
) { ) {
const file = await downloadRemoteFile(url, fileName, index, total) const file = await downloadRemoteFile(url, fileName, index, total)
const uploadedVideo = await uploadVideoFile(file, {
return new Promise<string>((resolve, reject) => { onUploadStarted(uploadItem) {
const uploader = new (window as any).AliyunUpload.Vod({ setStepProgress(index, total, 0.55, `正在上传视频:${uploadItem.file.name}`)
userId: '1303984639806000', },
region: 'cn-shanghai', onUploadSucceed(uploadItem) {
partSize: 1048576, setStepProgress(index, total, 0.95, `视频上传完成:${uploadItem.file.name}`)
parallel: 5, },
retryCount: 3, onUploadProgress(_uploadItem, progress) {
retryDuration: 2, setStepProgress(index, total, 0.55 + (progress / 100) * 0.4, `正在上传视频 ${Math.round(progress)}%:${fileName}`)
onUploadstarted(uploadInfo: any) { },
setStepProgress(index, total, 0.55, `正在上传视频:${uploadInfo.file.name}`)
getResourceUploadVideoAuth({ title: uploadInfo.file.name, file_name: uploadInfo.file.name })
.then((res: any) => {
uploader.setUploadAuthAndAddress(
uploadInfo,
res.data.upload_auth,
res.data.upload_address,
res.data.source_id,
)
})
.catch(reject)
},
onUploadSucceed(uploadInfo: any) {
setStepProgress(index, total, 0.95, `视频上传完成:${uploadInfo.file.name}`)
resolve(uploadInfo.videoId)
},
onUploadFailed(_uploadInfo: any, code: number, message: string) {
reject(new Error(message || `视频上传失败: ${code}`))
},
onUploadProgress(_uploadInfo: any, _totalSize: number, loadedPercent: number) {
setStepProgress(
index,
total,
0.55 + loadedPercent * 0.4,
`正在上传视频 ${Math.round(loadedPercent * 100)}%:${fileName}`,
)
},
onUploadTokenExpired(uploadInfo: any) {
updateResourceUploadVideoAuth({ source_id: uploadInfo.videoId })
.then((res: any) => {
uploader.resumeUploadWithAuth(res.data.UploadAuth || res.data.upload_auth || res.UploadAuth)
})
.catch(reject)
},
})
uploader.addFile(file, null, null, null, '{"Vod":{}}')
uploader.startUpload()
}) })
return {
sourceId: getUploadedVideoSourceId(uploadedVideo),
size: file.size,
}
} }
async function submitAuthorize() { async function submitAuthorize() {
...@@ -357,14 +322,14 @@ export function useCaseAuthorization() { ...@@ -357,14 +322,14 @@ export function useCaseAuthorization() {
setStepProgress(index, actionableItems.length, 0.05, `准备处理 ${index + 1}/${actionableItems.length}${file.name}`) setStepProgress(index, actionableItems.length, 0.05, `准备处理 ${index + 1}/${actionableItems.length}${file.name}`)
if (type === 'video') { if (type === 'video') {
const sourceId = await uploadVideoFromCase(file.url, file.name, index, actionableItems.length) const uploadedVideo = await uploadVideoFromCase(file.url, file.name, index, actionableItems.length)
if (authorizeToExperiment) { if (authorizeToExperiment) {
setStepProgress(index, actionableItems.length, 0.98, `正在创建实验操作视频:${file.name}`) setStepProgress(index, actionableItems.length, 0.98, `正在创建实验操作视频:${file.name}`)
await createVideo({ await createVideo({
experiment_id: selectedExperiment.value, experiment_id: selectedExperiment.value,
name: authorizedName, name: authorizedName,
source_id: sourceId, source_id: uploadedVideo.sourceId,
status: '1', status: '1',
}) })
successStats.experiment.video += 1 successStats.experiment.video += 1
...@@ -378,7 +343,8 @@ export function useCaseAuthorization() { ...@@ -378,7 +343,8 @@ export function useCaseAuthorization() {
classification: resourceClassification.value, classification: resourceClassification.value,
knowledge_points: '', knowledge_points: '',
cover: '', cover: '',
source_id: sourceId, source_id: uploadedVideo.sourceId,
size: uploadedVideo.size,
}) })
successStats.resource.video += 1 successStats.resource.video += 1
} }
......
...@@ -74,4 +74,5 @@ export interface ResourceVideoCreateItem { ...@@ -74,4 +74,5 @@ export interface ResourceVideoCreateItem {
knowledge_points: string knowledge_points: string
cover: string cover: string
source_id: string source_id: string
size?: number
} }
<script lang="ts" setup> <script lang="ts" setup>
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
// import { Plus } from '@element-plus/icons-vue' // import { Plus } from '@element-plus/icons-vue'
import type { UploadProps, UploadUserFile } from 'element-plus' import type { UploadProps, UploadRequestOptions, UploadUserFile } from 'element-plus'
import md5 from 'blueimp-md5' import { upload } from '@/utils/upload'
import { getSignature } from '@/api/base'
type UploadFileItem = { name: string; url: string } type UploadFileItem = { name: string; url: string }
...@@ -12,8 +11,6 @@ const props = withDefaults(defineProps<{ modelValue: string | UploadFileItem[]; ...@@ -12,8 +11,6 @@ const props = withDefaults(defineProps<{ modelValue: string | UploadFileItem[];
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const uploadData = ref()
const fileList = ref<UploadUserFile[]>([]) const fileList = ref<UploadUserFile[]>([])
watch( watch(
...@@ -27,21 +24,15 @@ const showFileList = computed(() => { ...@@ -27,21 +24,15 @@ const showFileList = computed(() => {
return Array.isArray(props.modelValue) return Array.isArray(props.modelValue)
}) })
// 上传之前 const handleUploadRequest = async (option: UploadRequestOptions) => {
const handleBeforeUpload = async (file: any) => { try {
const fileName = file.name const url = await upload(option.file)
const key = props.prefix + md5(fileName + new Date().getTime()) + fileName.substr(fileName.lastIndexOf('.')) ;(option.file as any).url = url
const response: Record<string, any> = await getSignature() ;(option.file as any).raw = Object.assign((option.file as any).raw || {}, { url })
uploadData.value = { option.onSuccess({ url })
key, } catch (error: any) {
host: response.host, option.onError(error)
OSSAccessKeyId: response.accessid,
policy: response.policy,
signature: response.signature,
success_action_status: '200',
url: `${response.host}/${key}`
} }
file.url = `${response.host}/${key}`
} }
// 上传成功 // 上传成功
...@@ -82,10 +73,9 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => { ...@@ -82,10 +73,9 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => {
<template> <template>
<el-upload <el-upload
:action="uploadData?.host" action="#"
:data="uploadData" :http-request="handleUploadRequest"
:show-file-list="false" :show-file-list="false"
:before-upload="handleBeforeUpload"
:on-exceed="handleExceed" :on-exceed="handleExceed"
:on-remove="handleRemove" :on-remove="handleRemove"
:on-preview="handlePreview" :on-preview="handlePreview"
......
<script lang="ts" setup> <script lang="ts" setup>
import { checkPermission } from '@/utils/permission' import { checkPermission } from '@/utils/permission'
import { getVideoPlayUrl } from '@/utils/video'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
...@@ -315,7 +316,7 @@ const handleConsult = (node: any) => { ...@@ -315,7 +316,7 @@ const handleConsult = (node: any) => {
// 视频 // 视频
if (node.data.resource_type === '2') { if (node.data.resource_type === '2') {
getVideoDetails({ id: node.data.resource_id }).then((res) => { getVideoDetails({ id: node.data.resource_id }).then((res) => {
videoUrl.value = res.data.play_auth.play_info_list.filter((item: any) => item.Definition === 'SD')[0].PlayURL videoUrl.value = getVideoPlayUrl(res.data.play_auth)
isShowVideoPlayDialog.value = true isShowVideoPlayDialog.value = true
}) })
} }
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { getVideoDetails } from '../api' import { getVideoDetails } from '../api'
import AppVideoPlayer from '@/components/base/AppVideoPlayer.vue' import AppVideoPlayer from '@/components/base/AppVideoPlayer.vue'
import ViewCourseChapter from './ViewCourseChapter.vue' import ViewCourseChapter from './ViewCourseChapter.vue'
import { getVideoPlayUrl } from '@/utils/video'
const route = useRoute() const route = useRoute()
...@@ -32,11 +33,9 @@ const videoOptions = computed(() => { ...@@ -32,11 +33,9 @@ const videoOptions = computed(() => {
return { return {
sources: [ sources: [
{ {
src: resourceData.play_auth?.play_info_list.find((item: any) => { src: getVideoPlayUrl(resourceData.play_auth),
return item.Definition === 'SD' },
}).PlayURL ],
}
]
} }
}) })
const video = computed<{ id: string }>(() => { const video = computed<{ id: string }>(() => {
......
...@@ -6,7 +6,15 @@ export function getVideoList(params: { tab: string; status?: string; authorized? ...@@ -6,7 +6,15 @@ export function getVideoList(params: { tab: string; status?: string; authorized?
} }
// 创建视频 // 创建视频
export function createVideo(data: { name: string; source: string; classification: string; knowledge_points: string; cover: string; source_id: string }) { export function createVideo(data: {
name: string
source: string
classification: string
knowledge_points: string
cover: string
source_id: string
size?: number
}) {
return httpRequest.post('/api/resource/v1/resource/video/create', data) return httpRequest.post('/api/resource/v1/resource/video/create', data)
} }
......
...@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus' ...@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus'
import { useGetCategoryList } from '@/composables/useGetCategoryList' import { useGetCategoryList } from '@/composables/useGetCategoryList'
import { createVideo } from '../api' import { createVideo } from '../api'
import UploadMultipleVideo from './UploadMultipleVideo.vue' import UploadMultipleVideo from './UploadMultipleVideo.vue'
import { getUploadedVideoSourceId } from '@/utils/videoUpload'
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
let { list: selectTree }: any = useGetCategoryList() let { list: selectTree }: any = useGetCategoryList()
const defaultProps = { children: 'children', label: 'category_name', value: 'id' } const defaultProps = { children: 'children', label: 'category_name', value: 'id' }
...@@ -23,26 +24,22 @@ const handleConfirm = () => { ...@@ -23,26 +24,22 @@ const handleConfirm = () => {
emit('update') emit('update')
} }
const handleCancel = () => { const handleCancel = () => {
const fileList = uploadMultipleVideoRef.value?.uploader?._uploadList || [] if (uploadMultipleVideoRef.value?.uploading) {
if (fileList.length) { return ElMessage.error('请先完成上传')
for (const item of fileList) {
if (item.state === 'Uploading') {
return ElMessage.error('请先完成上传')
}
}
} }
emit('update:modelValue', false) emit('update:modelValue', false)
} }
// 上传视频成功 // 上传视频成功
const uploadVideo = (data: any) => { const uploadVideo = (data: any) => {
const { file, videoId } = data const { file } = data
const params = { const params = {
name: file.name.slice(0, file.name.lastIndexOf('.')), name: file.name.slice(0, file.name.lastIndexOf('.')),
source: '2', source: '2',
classification: form.classification, classification: form.classification,
knowledge_points: '', knowledge_points: '',
source_id: videoId, source_id: getUploadedVideoSourceId(data),
cover: '' size: file.size,
cover: '',
} }
createVideo(params).then(() => { createVideo(params).then(() => {
ElMessage.success('视频上传成功') ElMessage.success('视频上传成功')
......
<script lang="ts" setup> <script lang="ts" setup>
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
// import { Plus } from '@element-plus/icons-vue' // import { Plus } from '@element-plus/icons-vue'
import type { UploadProps, UploadUserFile } from 'element-plus' import type { UploadProps, UploadRequestOptions, UploadUserFile } from 'element-plus'
import md5 from 'blueimp-md5' import { upload } from '@/utils/upload'
import { getSignature } from '@/api/base'
type UploadFileItem = { name: string; url: string } type UploadFileItem = { name: string; url: string }
...@@ -12,8 +11,6 @@ const props = withDefaults(defineProps<{ modelValue: string | UploadFileItem[]; ...@@ -12,8 +11,6 @@ const props = withDefaults(defineProps<{ modelValue: string | UploadFileItem[];
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const uploadData = ref()
const fileList = ref<UploadUserFile[]>([]) const fileList = ref<UploadUserFile[]>([])
watch( watch(
...@@ -27,21 +24,15 @@ const showFileList = computed(() => { ...@@ -27,21 +24,15 @@ const showFileList = computed(() => {
return Array.isArray(props.modelValue) return Array.isArray(props.modelValue)
}) })
// 上传之前 const handleUploadRequest = async (option: UploadRequestOptions) => {
const handleBeforeUpload = async (file: any) => { try {
const fileName = file.name const url = await upload(option.file)
const key = props.prefix + md5(fileName + new Date().getTime()) + fileName.substr(fileName.lastIndexOf('.')) ;(option.file as any).url = url
const response: Record<string, any> = await getSignature() ;(option.file as any).raw = Object.assign((option.file as any).raw || {}, { url })
uploadData.value = { option.onSuccess({ url })
key, } catch (error: any) {
host: response.host, option.onError(error)
OSSAccessKeyId: response.accessid,
policy: response.policy,
signature: response.signature,
success_action_status: '200',
url: `${response.host}/${key}`
} }
file.url = `${response.host}/${key}`
} }
// 上传成功 // 上传成功
...@@ -82,10 +73,9 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => { ...@@ -82,10 +73,9 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => {
<template> <template>
<el-upload <el-upload
:action="uploadData?.host" action="#"
:data="uploadData" :http-request="handleUploadRequest"
:show-file-list="false" :show-file-list="false"
:before-upload="handleBeforeUpload"
:on-exceed="handleExceed" :on-exceed="handleExceed"
:on-remove="handleRemove" :on-remove="handleRemove"
:on-preview="handlePreview" :on-preview="handlePreview"
......
<script setup lang="ts"> <script setup lang="ts">
import VideoDetail from './VideoDetail.vue' import VideoDetail from './VideoDetail.vue'
import { getCreateAuth, updateAuth } from '@/api/base'
import { CircleClose } from '@element-plus/icons-vue' import { CircleClose } from '@element-plus/icons-vue'
const idShowMore = ref(false) import type { VideoUploadItem } from '@/utils/videoUpload'
import { createVideoUploader } from '@/utils/videoUpload'
// uploadInfo 包含要上传的文件信息
interface UploadInfo {
bucket: string
checkpoint: { file: File; name: string; fileSize: number; partSize: number; uploadId: string }
endpoint: string
file: File
fileHash: string
isImage: boolean
loaded: number
object: string
region: string
retry: boolean
ri: string
state: string
userData: string
videoId: string
videoInfo: any
progress: number
}
const idShowMore = ref(false)
const emit = defineEmits(['upload', 'canClose']) const emit = defineEmits(['upload', 'canClose'])
let uploader = createUploader() const { files: fileList, uploading, addFile, cancelUpload } = createVideoUploader({
const fileList = ref<UploadInfo[]>([]) multiple: true,
onUploadStarted() {
emit('canClose', { closeStatus: false })
},
onUploadSucceed(file) {
emit('upload', file)
},
onUploadEnd() {
emit('canClose', { closeStatus: true })
},
})
const fileChange = (event: Event) => { const fileChange = (event: Event) => {
const element = event.currentTarget as HTMLInputElement const element = event.currentTarget as HTMLInputElement
let files: FileList | null = element.files const files = element.files
if (!files) return if (!files) return
for (const file of files) { for (const file of files) {
// 是否重复上传 addFile(file)
const hasRepeat = !!fileList.value.find(
item =>
item.file.name === file.name && item.file.size === file.size && item.file.lastModified === file.lastModified
)
!hasRepeat && uploader.addFile(file, null, null, null, '{"Vod":{}}')
} }
uploader.startUpload()
fileList.value = uploader._uploadList
}
function updateFileList(uploadInfo: UploadInfo) {
if (!uploadInfo) return
fileList.value = fileList.value.map(item => {
if (item.ri === uploadInfo.ri) {
return { ...item, ...uploadInfo }
}
return item
})
}
function createUploader() {
return new window['AliyunUpload'].Vod({
//userID,必填,您可以使用阿里云账号访问账号中心(https://account.console.aliyun.com/),即可查看账号ID
userId: '1303984639806000',
//上传到视频点播的地域,默认值为'cn-shanghai',
//eu-central-1,ap-southeast-1
region: 'cn-shanghai',
//分片大小默认1 MB,不能小于100 KB(100*1024)
partSize: 1048576,
//并行上传分片个数,默认5
parallel: 5,
//网络原因失败时,重新上传次数,默认为3
retryCount: 3,
//网络原因失败时,重新上传间隔时间,默认为2秒
retryDuration: 2,
//开始上传
onUploadstarted: onUploadStarted,
//文件上传成功
onUploadSucceed: onUploadSucceed,
//文件上传失败
onUploadFailed: onUploadFailed,
//文件上传进度,单位:字节
onUploadProgress: onUploadProgress,
//上传凭证或STS token超时
onUploadTokenExpired: onUploadTokenExpired,
//全部文件上传结束
onUploadEnd: onUploadEnd
})
}
// 开始上传
function onUploadStarted(uploadInfo: UploadInfo) {
console.log('onUploadStarted', uploadInfo)
getCreateAuth({ title: uploadInfo.file.name, file_name: uploadInfo.file.name }).then(res => {
uploader.setUploadAuthAndAddress(uploadInfo, res.data.upload_auth, res.data.upload_address, res.data.source_id)
})
updateFileList(uploadInfo)
emit('canClose', { closeStatus: false })
}
// 文件上传成功
function onUploadSucceed(uploadInfo: UploadInfo) {
console.log('onUploadSucceed', uploadInfo)
updateFileList(uploadInfo)
emit('upload', uploadInfo)
}
//文件上传失败
function onUploadFailed(uploadInfo: UploadInfo, code: number, message: string) {
console.log(uploadInfo, '111111')
console.log('onUploadFailed', uploadInfo, code, message)
updateFileList(uploadInfo)
}
//文件上传进度,单位:字节
function onUploadProgress(uploadInfo: UploadInfo, totalSize: number, loadedPercent: number) {
console.log('onUploadProgress', uploadInfo.file.name, uploadInfo, totalSize, loadedPercent)
updateFileList(uploadInfo)
}
//上传凭证或STS token超时
function onUploadTokenExpired(uploadInfo: UploadInfo) {
console.log('onUploadTokenExpired', uploadInfo)
updateAuth({ source_id: uploadInfo.videoId }).then(res => {
uploader.resumeUploadWithAuth(res.data.UploadAuth)
})
updateFileList(uploadInfo)
}
// 全部文件上传结束
function onUploadEnd(uploadInfo: UploadInfo) {
console.log('onUploadEnd', uploadInfo)
updateFileList(uploadInfo)
emit('canClose', { closeStatus: true })
} }
const handleView = () => { const handleView = () => {
idShowMore.value = true idShowMore.value = true
} }
// 进度条
function percentage(value: number) {
return parseFloat((value ? value * 100 : 0).toFixed(2))
}
// 删除上传文件 const deleteFile = (item: VideoUploadItem) => {
const deleteFile = function (index: number) { cancelUpload(item)
console.log(index, 'deleteFile', fileList)
fileList.value.splice(index, 1)
uploader.deleteFile(index)
} }
defineExpose({ uploader, fileList })
defineExpose({ files: fileList, uploading, cancelUpload })
</script> </script>
<template> <template>
<div class="upload-video" style="display: flex; flex-direction: column; align-items: flex-start"> <div class="upload-video" style="display: flex; flex-direction: column; align-items: flex-start">
<div class="upload-btn"> <div class="upload-btn">
...@@ -149,9 +50,9 @@ defineExpose({ uploader, fileList }) ...@@ -149,9 +50,9 @@ defineExpose({ uploader, fileList })
<div v-for="(item, index) in fileList.slice(0, 3)" :key="index"> <div v-for="(item, index) in fileList.slice(0, 3)" :key="index">
<div class="video-info"> <div class="video-info">
<span class="name">{{ item.file?.name }}</span> <span class="name">{{ item.file?.name }}</span>
<el-progress style="width: 200px" :percentage="percentage(item.loaded)" class="view" /> <el-progress style="width: 200px" :percentage="item.progress" class="view" />
<div v-if="percentage(item.loaded) == 100">上传成功</div> <div v-if="item.progress === 100">上传成功</div>
<el-icon v-else @click="deleteFile(index)"><CircleClose /></el-icon> <el-icon v-else @click="deleteFile(item)"><CircleClose /></el-icon>
</div> </div>
</div> </div>
<el-link style="padding-top: 3px" :underline="false" type="primary" v-if="fileList.length > 3" @click="handleView" <el-link style="padding-top: 3px" :underline="false" type="primary" v-if="fileList.length > 3" @click="handleView"
...@@ -160,6 +61,7 @@ defineExpose({ uploader, fileList }) ...@@ -160,6 +61,7 @@ defineExpose({ uploader, fileList })
</div> </div>
<VideoDetail :videoList="fileList" v-model:modelValue="idShowMore" v-if="idShowMore === true" /> <VideoDetail :videoList="fileList" v-model:modelValue="idShowMore" v-if="idShowMore === true" />
</template> </template>
<style lang="scss"> <style lang="scss">
.demo-progress { .demo-progress {
min-width: 350px; min-width: 350px;
......
<script setup lang="ts"> <script setup lang="ts">
import { getCreateAuth, updateAuth } from '@/api/base'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
/** import { createVideoUploader } from '@/utils/videoUpload'
* upload 上传状态 {code: -1(待上传)0(成功) 1(开始上传) 2(上传失败), msg: '上传信息'}
* progress 上传进度
**/
interface IUpload {
code: number
name?: string
msg?: string
progress?: number
videoId?: string
}
const uploadData = ref<IUpload>({ code: -1 })
const emit = defineEmits(['upload']) const emit = defineEmits(['upload'])
const form: any = reactive({
timeout: '', const uploadState = reactive({
partSize: '', code: -1,
parallel: '', name: '',
retryCount: '', progress: 0,
retryDuration: '', })
region: 'cn-shanghai',
userId: '1303984639806000', const { files, uploading, addFile, stopUpload } = createVideoUploader({
file: null, onUploadStarted(file) {
authProgress: 0, uploadState.code = 1
uploader: null, uploadState.name = file.name
statusText: '' uploadState.progress = 0
},
onUploadProgress(_file, progress) {
uploadState.code = 1
uploadState.progress = progress
},
onUploadSucceed(file) {
uploadState.code = 0
uploadState.name = file.name
uploadState.progress = 100
emit('upload', file)
},
onUploadFailed(file) {
uploadState.code = 2
uploadState.name = file.name
},
}) })
const fileChange = (e: any) => { const fileChange = (e: any) => {
form.file = e.target.files[0] const file = e.target.files[0]
if (form.file.name.indexOf('.mp4') === -1) { if (!file) return
if (file.name.indexOf('.mp4') === -1) {
ElMessage('请上传mp4格式视频') ElMessage('请上传mp4格式视频')
return return
} }
var userData = '{"Vod":{}}' if (files.value.length) {
if (form.uploader) { stopUpload()
form.uploader.stopUpload()
form.authProgress = 0
form.statusText = ''
} }
form.uploader = createUploader() addFile(file)
form.uploader.addFile(form.file, null, null, null, userData)
form.uploader.startUpload()
}
const createUploader: any = () => {
const w = window as any
const uploader = new w.AliyunUpload.Vod({
timeout: form.timeout || 60000,
partSize: form.partSize || 1048576,
parallel: form.parallel || 5,
retryCount: form.retryCount || 3,
retryDuration: form.retryDuration || 2,
region: form.region,
userId: form.userId,
// 开始上传
onUploadstarted: function (uploadInfo: any) {
const fileData = JSON.parse(window.localStorage.fileData || '{}')
// 判断有没有上传过
const isFile = !!fileData.sourceId
if (!isFile) {
// 没上传过请求凭证上传
getCreateAuth({ title: uploadInfo.file.name, file_name: uploadInfo.file.name }).then((data: any) => {
window.localStorage.fileData = JSON.stringify({
uploadAuth: data.data.upload_auth,
uploadAddress: data.data.upload_address,
videoId: data.data.source_id,
fileName: uploadInfo.file.name,
fileSize: uploadInfo.file.size
})
uploader.setUploadAuthAndAddress(
uploadInfo,
data.data.upload_auth,
data.data.upload_address,
data.data.source_id
)
})
} else {
// 上传过判断一下上次上传的文件和本次上传的文件一不一样,一样的话继续上传
if (fileData.fileName === uploadInfo.file.name && fileData.fileSize === uploadInfo.file.size) {
uploader.setUploadAuthAndAddress(uploadInfo, fileData.uploadAuth, fileData.uploadAddress, fileData.videoId)
} else {
getCreateAuth({ title: uploadInfo.file.name, file_name: uploadInfo.file.name }).then((data: any) => {
uploader.setUploadAuthAndAddress(
uploadInfo,
data.data.upload_auth,
data.data.upload_address,
data.data.source_id
)
})
}
}
uploadData.value = {
code: 1,
name: uploadInfo.file.name,
msg: '开始上传'
}
},
// 文件上传成功
onUploadSucceed: function (uploadInfo: any) {
const fileData = window.localStorage.fileData ? JSON.parse(window.localStorage.fileData) : {}
uploadData.value = {
code: 0,
name: uploadInfo.file.name,
videoId: fileData.videoId,
msg: '上传成功'
}
emit('upload', { videoId: fileData.videoId, name: uploadInfo.file.name })
},
// 文件上传失败
// code:any, message:any
onUploadFailed: function (uploadInfo: any) {
uploadData.value = {
code: 2,
name: uploadInfo.file.name,
msg: '文件上传失败'
}
},
// 文件上传进度,单位:字节, 可以在这个函数中拿到上传进度并显示在页面上
onUploadProgress: function (uploadInfo: any, totalSize: any, progress: any) {
let progressPercent = Math.ceil(progress * 100)
form.authProgress = progressPercent
uploadData.value.progress = progressPercent
},
// 上传凭证超时
onUploadTokenExpired: function (uploadInfo: any) {
const fileData = JSON.parse(window.localStorage.fileData || '{}')
updateAuth({ source_id: fileData.videoId }).then(({ data }) => {
let uploadAuth = data.UploadAuth
window.localStorage.fileData = JSON.stringify({
uploadAuth: data.data.upload_auth,
uploadAddress: data.data.upload_address,
videoId: data.data.source_id,
fileName: uploadInfo.file.name,
fileSize: uploadInfo.file.size
})
uploader.resumeUploadWithAuth(uploadAuth)
})
}
})
return uploader
} }
</script> </script>
<template> <template>
...@@ -151,15 +53,15 @@ const createUploader: any = () => { ...@@ -151,15 +53,15 @@ const createUploader: any = () => {
<!-- accept=".mp4" --> <!-- accept=".mp4" -->
<input accept=".mp4" type="file" id="fileUpload" @change="fileChange($event)" /> <input accept=".mp4" type="file" id="fileUpload" @change="fileChange($event)" />
</div> </div>
<div class="demo-progress" v-if="uploadData.code === 1"> <div class="demo-progress" v-if="uploading || uploadState.code === 1">
<el-progress style="width: 340px" :percentage="uploadData.progress" /> <el-progress style="width: 340px" :percentage="uploadState.progress" />
<!-- <span> {{ uploadData.progress }}% </span> --> <!-- <span> {{ uploadData.progress }}% </span> -->
</div> </div>
<div class="error video-info" v-if="uploadData.code === 2"> <div class="error video-info" v-if="uploadState.code === 2">
<div class="name">上传失败(请重新选择文件进行上传)</div> <div class="name">上传失败(请重新选择文件进行上传)</div>
</div> </div>
<div class="video-info" v-if="uploadData.code === 0"> <div class="video-info" v-if="uploadState.code === 0">
<div class="name">{{ uploadData.name }}</div> <div class="name">{{ uploadState.name }}</div>
</div> </div>
</div> </div>
<div class="tips">推荐视频格式:帧率为25fps\输出码率为4M\输出格式为mp4,建议采用格式工厂等工具处理后上传。</div> <div class="tips">推荐视频格式:帧率为25fps\输出码率为4M\输出格式为mp4,建议采用格式工厂等工具处理后上传。</div>
......
<script lang="ts" setup> <script lang="ts" setup>
interface UploadInfo { import type { VideoUploadItem } from '@/utils/videoUpload'
bucket: string
checkpoint: { file: File; name: string; fileSize: number; partSize: number; uploadId: string }
endpoint: string
file: File
fileHash: string
isImage: boolean
loaded: number
object: string
region: string
retry: boolean
ri: string
state: string
userData: string
videoId: string
videoInfo: any
progress: number
}
interface Props { interface Props {
videoList: UploadInfo[] videoList: VideoUploadItem[]
modelValue: boolean modelValue: boolean
} }
interface Emits { interface Emits {
...@@ -40,9 +24,6 @@ const handleSizeChange = (val: any) => { ...@@ -40,9 +24,6 @@ const handleSizeChange = (val: any) => {
const handleCurrentChange = (val: any) => { const handleCurrentChange = (val: any) => {
page.currentPage = val page.currentPage = val
} }
function percentage(value: number) {
return parseFloat((value ? value * 100 : 0).toFixed(2))
}
</script> </script>
<template> <template>
<el-dialog :model-value="props.modelValue" title="更多视频文件" :before-close="handleCancel" width="30vw"> <el-dialog :model-value="props.modelValue" title="更多视频文件" :before-close="handleCancel" width="30vw">
...@@ -52,7 +33,7 @@ function percentage(value: number) { ...@@ -52,7 +33,7 @@ function percentage(value: number) {
> >
<div class="video-info"> <div class="video-info">
<span class="name">{{ item.file?.name }}</span> <span class="name">{{ item.file?.name }}</span>
<el-progress style="width: 200px" :percentage="percentage(item.loaded)" class="view" /> <el-progress style="width: 200px" :percentage="item.progress" class="view" />
</div> </div>
</div> </div>
<el-pagination <el-pagination
......
<script setup lang="ts"> <script setup lang="ts">
import AppVideoPlayer from '@/components/base/AppVideoPlayer.vue' import AppVideoPlayer from '@/components/base/AppVideoPlayer.vue'
import { getVideoPlayUrl } from '@/utils/video'
const props = defineProps(['data']) const props = defineProps(['data'])
const videoOptions = computed(() => { const videoOptions = computed(() => {
return { return {
sources: [ sources: [
{ {
src: props.data.play_auth.play_info_list.find((item: any) => { src: getVideoPlayUrl(props.data.play_auth),
return item.Definition === 'SD' },
}).PlayURL ],
}
]
} }
}) })
</script> </script>
......
...@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus' ...@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus'
import { useGetCategoryList } from '@/composables/useGetCategoryList' import { useGetCategoryList } from '@/composables/useGetCategoryList'
import { createVideo } from '../api' import { createVideo } from '../api'
import UploadMultipleVideo from '../components/UploadMultipleVideo.vue' import UploadMultipleVideo from '../components/UploadMultipleVideo.vue'
import { getUploadedVideoSourceId } from '@/utils/videoUpload'
// const emit = defineEmits<Emits>() // const emit = defineEmits<Emits>()
...@@ -39,14 +40,15 @@ const uploadMultipleVideoRef = ref<InstanceType<typeof UploadMultipleVideo> | nu ...@@ -39,14 +40,15 @@ const uploadMultipleVideoRef = ref<InstanceType<typeof UploadMultipleVideo> | nu
// } // }
// 上传视频成功 // 上传视频成功
const uploadVideo = (data: any) => { const uploadVideo = (data: any) => {
const { file, videoId } = data const { file } = data
const params = { const params = {
name: file.name.slice(0, file.name.lastIndexOf('.')), name: file.name.slice(0, file.name.lastIndexOf('.')),
source: '2', source: '2',
classification: form.classification, classification: form.classification,
knowledge_points: '', knowledge_points: '',
source_id: videoId, source_id: getUploadedVideoSourceId(data),
cover: '' size: file.size,
cover: '',
} }
createVideo(params).then(() => { createVideo(params).then(() => {
ElMessage.success('视频上传成功') ElMessage.success('视频上传成功')
......
...@@ -7,6 +7,7 @@ import UploadVideo from '../components/UploadVideo.vue' ...@@ -7,6 +7,7 @@ import UploadVideo from '../components/UploadVideo.vue'
import { getCoverList, createVideo, getVideoDetails, updateVideo } from '../api' import { getCoverList, createVideo, getVideoDetails, updateVideo } from '../api'
import { useGetCategoryList } from '@/composables/useGetCategoryList' import { useGetCategoryList } from '@/composables/useGetCategoryList'
import Protocol from '@/components/base/Protocol.vue' import Protocol from '@/components/base/Protocol.vue'
import { getUploadedVideoSourceId } from '@/utils/videoUpload'
// 路由 // 路由
const router = useRouter() const router = useRouter()
...@@ -60,7 +61,7 @@ const swiperItemHandle = (url: string) => { ...@@ -60,7 +61,7 @@ const swiperItemHandle = (url: string) => {
// form表单 // form表单
let form = reactive({ let form = reactive({
data: { name: '', source: '2', classification: '', knowledge_points: '', cover: '', source_id: '' } data: { name: '', source: '2', classification: '', knowledge_points: '', cover: '', source_id: '', size: 0 }
}) })
// 表单验证 // 表单验证
const rules = { const rules = {
...@@ -139,7 +140,8 @@ const protocol = ref(false) ...@@ -139,7 +140,8 @@ const protocol = ref(false)
// 上传视频成功 // 上传视频成功
const uploadVideo = (data: any) => { const uploadVideo = (data: any) => {
form.data.source_id = data.videoId form.data.source_id = getUploadedVideoSourceId(data)
form.data.size = data.file?.size || 0
const name = data.name const name = data.name
form.data.name = name.slice(0, name.lastIndexOf('.')) form.data.name = name.slice(0, name.lastIndexOf('.'))
} }
......
<template>
<el-dialog
:model-value="props.visible"
@update:model-value="emit('update:visible', $event)"
:title="isEdit ? '编辑大模型配置' : '新增大模型配置'"
width="800px"
:before-close="handleClose">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" class="llm-config-form">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="配置名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入配置名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="提供商" prop="provider">
<el-select v-model="formData.provider" placeholder="请选择提供商" @change="handleProviderChange">
<el-option
v-for="provider in providerOptions"
:key="provider.value"
:label="provider.label"
:value="provider.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="模型名称" prop="model_name">
<el-select v-model="formData.model_name" placeholder="请选择模型" filterable>
<el-option v-for="model in modelOptions" :key="model.value" :label="model.label" :value="model.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="API地址" prop="api_url">
<el-input v-model="formData.api_url" placeholder="请输入API地址" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="API密钥" prop="api_key">
<el-input v-model="formData.api_key" type="password" placeholder="请输入API密钥" show-password />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入配置描述" />
</el-form-item>
<el-divider content-position="left">高级参数配置</el-divider>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="最大令牌数" prop="max_tokens">
<el-input-number
v-model="formData.max_tokens"
:min="1"
:max="100000"
controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="温度" prop="temperature">
<el-input-number
v-model="formData.temperature"
:min="0"
:max="2"
:step="0.1"
controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Top P" prop="top_p">
<el-input-number
v-model="formData.top_p"
:min="0"
:max="1"
:step="0.1"
controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="频率惩罚" prop="frequency_penalty">
<el-input-number
v-model="formData.frequency_penalty"
:min="-2"
:max="2"
:step="0.1"
controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="存在惩罚" prop="presence_penalty">
<el-input-number
v-model="formData.presence_penalty"
:min="-2"
:max="2"
:step="0.1"
controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="启用状态" prop="is_enabled">
<el-switch v-model="formData.is_enabled" />
<span class="form-tip">启用后该配置将可用于文本处理</span>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { llmConfigStorage } from '@/utils/llmStorage'
interface Props {
visible: boolean
editData?: any
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
// 提供商选项
const providerOptions = [
{ label: 'DeepSeek', value: 'deepseek' },
{ label: '通义千问 (Qwen)', value: 'qwen' },
{ label: '自定义', value: 'custom' },
]
// 模型选项(根据提供商动态加载)
const modelOptions = ref<Array<{ label: string; value: string }>>([])
// 表单数据
const formData = reactive({
name: '',
provider: '',
model_name: '',
api_key: '',
api_url: '',
max_tokens: 4000,
temperature: 0.7,
top_p: 0.9,
frequency_penalty: 0,
presence_penalty: 0,
is_enabled: true,
description: '',
})
// 表单验证规则
const rules: FormRules = {
name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' },
{ min: 2, max: 50, message: '配置名称长度在 2 到 50 个字符', trigger: 'blur' },
],
provider: [{ required: true, message: '请选择提供商', trigger: 'change' }],
model_name: [{ required: true, message: '请选择模型名称', trigger: 'change' }],
api_key: [{ required: true, message: '请输入API密钥', trigger: 'blur' }],
api_url: [
{ required: true, message: '请输入API地址', trigger: 'blur' },
{ type: 'url', message: '请输入正确的URL格式', trigger: 'blur' },
],
max_tokens: [{ required: true, message: '请输入最大令牌数', trigger: 'blur' }],
}
// 计算属性
const isEdit = computed(() => !!props.editData)
// 监听对话框显示状态
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.editData) {
Object.assign(formData, props.editData)
} else {
resetForm()
}
loadModelOptions()
}
}
)
// 监听提供商变化
watch(
() => formData.provider,
() => {
loadModelOptions()
formData.model_name = ''
}
)
// 重置表单
const resetForm = () => {
Object.assign(formData, {
name: '',
provider: '',
model_name: '',
api_key: '',
api_url: '',
max_tokens: 4000,
temperature: 0.7,
top_p: 0.9,
frequency_penalty: 0,
presence_penalty: 0,
is_enabled: true,
description: '',
})
formRef.value?.clearValidate()
}
// 加载模型选项
const loadModelOptions = () => {
modelOptions.value = getDefaultModels(formData.provider)
}
// 获取默认模型列表
const getDefaultModels = (provider: string) => {
const modelMap: Record<string, Array<{ label: string; value: string }>> = {
deepseek: [
{ label: 'DeepSeek-V2', value: 'deepseek-chat' },
{ label: 'DeepSeek-Coder', value: 'deepseek-coder' },
],
qwen: [
{ label: 'Qwen2.5-72B-Instruct', value: 'qwen2.5-72b-instruct' },
{ label: 'Qwen2.5-32B-Instruct', value: 'qwen2.5-32b-instruct' },
{ label: 'Qwen2.5-14B-Instruct', value: 'qwen2.5-14b-instruct' },
{ label: 'Qwen2.5-7B-Instruct', value: 'qwen2.5-7b-instruct' },
],
custom: [{ label: '自定义模型', value: 'custom' }],
}
return modelMap[provider] || []
}
// 处理提供商变化
const handleProviderChange = () => {
// 根据提供商设置默认API地址
const defaultUrls: Record<string, string> = {
deepseek: 'https://api.deepseek.com/v1/chat/completions',
qwen: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation',
custom: '',
}
formData.api_url = defaultUrls[formData.provider] || ''
}
// 移除测试连接功能
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate()
if (!valid) return
submitting.value = true
try {
if (isEdit.value && props.editData?.id) {
llmConfigStorage.update(props.editData.id, formData)
ElMessage.success('更新成功')
} else {
llmConfigStorage.create(formData)
ElMessage.success('创建成功')
}
emit('success')
handleClose()
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
} finally {
submitting.value = false
}
}
// 关闭对话框
const handleClose = () => {
emit('update:visible', false)
resetForm()
}
</script>
<style lang="scss" scoped>
.llm-config-form {
.form-tip {
margin-left: 10px;
color: #909399;
font-size: 12px;
}
}
.dialog-footer {
text-align: right;
}
</style>
<template>
<div class="llm-config-list">
<!-- 搜索栏 -->
<div class="search-bar">
<el-row :gutter="20">
<el-col :span="6">
<el-input
v-model="searchForm.name"
placeholder="请输入配置名称"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-col>
<el-col :span="4">
<el-select v-model="searchForm.provider" placeholder="选择提供商" clearable @change="handleSearch">
<el-option
v-for="provider in providerOptions"
:key="provider.value"
:label="provider.label"
:value="provider.value" />
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="searchForm.is_enabled" placeholder="启用状态" clearable @change="handleSearch">
<el-option label="已启用" value="true" />
<el-option label="已禁用" value="false" />
</el-select>
</el-col>
<el-col :span="6">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-col>
<el-col :span="4" class="text-right">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增配置
</el-button>
</el-col>
</el-row>
</div>
<!-- 表格 -->
<el-table
v-loading="loading"
:data="tableData"
stripe
style="width: 100%"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="配置名称" min-width="150" />
<el-table-column prop="provider" label="提供商" width="120">
<template #default="{ row }">
<el-tag :type="getProviderTagType(row.provider) as any">
{{ getProviderLabel(row.provider) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="model_name" label="模型名称" min-width="180" />
<el-table-column prop="api_url" label="API地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="max_tokens" label="最大令牌" width="100" />
<el-table-column prop="temperature" label="温度" width="80" />
<el-table-column prop="is_enabled" label="状态" width="80">
<template #default="{ row }">
<el-switch v-model="row.is_enabled" @change="handleToggleStatus(row)" :loading="row.statusLoading" />
</template>
</el-table-column>
<el-table-column prop="created_time" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)"> 编辑 </el-button>
<el-button type="danger" size="small" @click="handleDelete(row)"> 删除 </el-button>
</template>
</el-table-column>
</el-table>
<!-- 移除分页组件 -->
<!-- 批量操作 -->
<div v-if="selectedRows.length > 0" class="batch-actions">
<el-alert :title="`已选择 ${selectedRows.length} 项`" type="info" show-icon :closable="false">
<template #default>
<el-button type="danger" size="small" @click="handleBatchDelete"> 批量删除 </el-button>
<el-button type="warning" size="small" @click="handleBatchDisable"> 批量禁用 </el-button>
<el-button type="success" size="small" @click="handleBatchEnable"> 批量启用 </el-button>
</template>
</el-alert>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { llmConfigStorage } from '@/utils/llmStorage'
interface Props {
onEdit: (data: any) => void
}
const props = defineProps<Props>()
// 搜索表单
const searchForm = reactive({
name: '',
provider: '',
is_enabled: '',
})
// 提供商选项
const providerOptions = [
{ label: 'DeepSeek', value: 'deepseek' },
{ label: '通义千问', value: 'qwen' },
{ label: '自定义', value: 'custom' },
]
// 表格数据
const tableData = ref<any[]>([])
const loading = ref(false)
const selectedRows = ref<any[]>([])
// 移除分页相关变量
// 获取提供商标签类型
const getProviderTagType = (provider: string) => {
const typeMap: Record<string, string> = {
deepseek: 'primary',
qwen: 'success',
custom: 'danger',
}
return typeMap[provider] || 'info'
}
// 获取提供商标签
const getProviderLabel = (provider: string) => {
const option = providerOptions.find((item) => item.value === provider)
return option?.label || provider
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
// 加载数据
const loadData = () => {
loading.value = true
try {
// 获取所有数据
let allData = llmConfigStorage.getAll()
// 应用搜索过滤
if (searchForm.name) {
allData = allData.filter((item: any) => item.name.toLowerCase().includes(searchForm.name.toLowerCase()))
}
if (searchForm.provider) {
allData = allData.filter((item: any) => item.provider === searchForm.provider)
}
if (searchForm.is_enabled) {
const enabled = searchForm.is_enabled === 'true'
allData = allData.filter((item: any) => item.is_enabled === enabled)
}
// 直接显示所有数据,不分页
tableData.value = allData
} catch (error: any) {
ElMessage.error(error.message || '加载数据失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
loadData()
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, {
name: '',
provider: '',
is_enabled: '',
})
handleSearch()
}
// 新增
const handleAdd = () => {
props.onEdit({})
}
// 编辑
const handleEdit = (row: any) => {
props.onEdit(row)
}
// 移除测试连接功能
// 删除
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要删除配置 "${row.name}" 吗?`, '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
if (row.id) {
llmConfigStorage.delete(row.id)
ElMessage.success('删除成功')
loadData()
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}
// 切换状态
const handleToggleStatus = async (row: any) => {
if (!row.id) return
row.statusLoading = true
try {
llmConfigStorage.update(row.id, { is_enabled: row.is_enabled })
ElMessage.success(row.is_enabled ? '启用成功' : '禁用成功')
} catch (error: any) {
row.is_enabled = !row.is_enabled // 回滚状态
ElMessage.error(error.message || '操作失败')
} finally {
row.statusLoading = false
}
}
// 选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 批量删除
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedRows.value.length} 个配置吗?`, '确认批量删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
selectedRows.value.filter((item) => item.id).forEach((item) => llmConfigStorage.delete(item.id))
ElMessage.success('批量删除成功')
loadData()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '批量删除失败')
}
}
}
// 批量禁用
const handleBatchDisable = async () => {
try {
selectedRows.value
.filter((item) => item.id)
.forEach((item) => llmConfigStorage.update(item.id, { is_enabled: false }))
ElMessage.success('批量禁用成功')
loadData()
} catch (error: any) {
ElMessage.error(error.message || '批量禁用失败')
}
}
// 批量启用
const handleBatchEnable = async () => {
try {
selectedRows.value
.filter((item) => item.id)
.forEach((item) => llmConfigStorage.update(item.id, { is_enabled: true }))
ElMessage.success('批量启用成功')
loadData()
} catch (error: any) {
ElMessage.error(error.message || '批量启用失败')
}
}
// 移除分页变化方法
// 暴露刷新方法
defineExpose({
refresh: loadData,
})
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.llm-config-list {
.search-bar {
margin-bottom: 20px;
padding: 20px;
background: #f5f7fa;
border-radius: 6px;
}
.pagination-wrapper {
margin-top: 20px;
text-align: right;
}
.batch-actions {
margin-top: 20px;
}
.text-right {
text-align: right;
}
}
</style>
<template>
<div class="text-process-config">
<el-card class="config-card">
<template #header>
<div class="card-header">
<span>文本处理配置</span>
<el-button type="primary" @click="handleSave" :loading="saving"> 保存配置 </el-button>
</div>
</template>
<el-form ref="formRef" :model="configData" :rules="rules" label-width="200px" class="config-form">
<!-- 意识形态检测配置 -->
<el-divider content-position="left">意识形态检测配置</el-divider>
<el-form-item label="启用意识形态检测" prop="enable_ideology_check">
<el-switch v-model="configData.enable_ideology_check" />
<div class="form-tip">启用后将对文本进行意识形态相关内容的检测和过滤</div>
</el-form-item>
<!-- 敏感词过滤配置 -->
<el-divider content-position="left">敏感词过滤配置</el-divider>
<el-form-item label="启用敏感词过滤" prop="enable_sensitive_word_filter">
<el-switch v-model="configData.enable_sensitive_word_filter" />
<div class="form-tip">启用后将检测并处理文本中的敏感词</div>
</el-form-item>
<el-form-item
v-if="configData.enable_sensitive_word_filter"
label="自动替换敏感词"
prop="auto_replace_sensitive_words">
<el-switch v-model="configData.auto_replace_sensitive_words" />
<div class="form-tip">启用后自动将敏感词替换为指定内容</div>
</el-form-item>
<el-form-item
v-if="configData.enable_sensitive_word_filter && configData.auto_replace_sensitive_words"
label="自定义替换内容"
prop="custom_replacement">
<el-input
v-model="configData.custom_replacement"
placeholder="请输入替换内容,默认为***"
maxlength="50"
show-word-limit />
<div class="form-tip">当检测到敏感词时,将替换为此内容</div>
</el-form-item>
<!-- 敏感人物过滤配置 -->
<el-divider content-position="left">敏感人物过滤配置</el-divider>
<el-form-item label="启用敏感人物过滤" prop="enable_sensitive_person_filter">
<el-switch v-model="configData.enable_sensitive_person_filter" />
<div class="form-tip">启用后将检测并处理文本中涉及的敏感人物</div>
</el-form-item>
</el-form>
</el-card>
<!-- 移除测试区域 -->
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { textProcessStorage } from '@/utils/llmStorage'
// 表单引用
const formRef = ref<FormInstance>()
const saving = ref(false)
// 配置数据
const configData = reactive({
enable_ideology_check: true,
enable_sensitive_word_filter: true,
enable_sensitive_person_filter: true,
auto_replace_sensitive_words: true,
custom_replacement: '***',
})
// 移除测试相关变量
// 表单验证规则
const rules: FormRules = {
custom_replacement: [{ max: 50, message: '替换内容不能超过50个字符', trigger: 'blur' }],
}
// 获取级别标签类型
const getLevelTagType = (level: string) => {
const typeMap: Record<string, string> = {
low: 'info',
medium: 'warning',
high: 'danger',
}
return typeMap[level] || 'info'
}
// 获取级别标签
const getLevelLabel = (level: string) => {
const labelMap: Record<string, string> = {
low: '低',
medium: '中',
high: '高',
}
return labelMap[level] || level
}
// 移除测试相关方法
// 加载配置
const loadConfig = () => {
try {
const config = textProcessStorage.get()
Object.assign(configData, config)
} catch (error: any) {
ElMessage.error(error.message || '加载配置失败')
}
}
// 保存配置
const handleSave = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
textProcessStorage.update(configData)
ElMessage.success('配置保存成功')
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
// 移除测试文本处理功能
onMounted(() => {
loadConfig()
})
</script>
<style lang="scss" scoped>
.text-process-config {
.config-card,
.test-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.config-form {
.form-tip {
color: #909399;
font-size: 12px;
margin-top: 5px;
}
}
.test-result {
margin-top: 20px;
.result-section {
margin-bottom: 20px;
h4 {
margin-bottom: 10px;
color: #303133;
}
}
.sensitive-words,
.sensitive-persons {
margin-top: 10px;
.word-tag,
.person-tag {
margin-right: 8px;
margin-bottom: 8px;
}
}
.processed-text {
padding: 15px;
background: #f5f7fa;
border-radius: 6px;
border: 1px solid #e4e7ed;
line-height: 1.6;
color: #606266;
}
.score-tip {
margin-top: 10px;
color: #909399;
font-size: 12px;
}
.score-text {
color: #fff;
font-weight: bold;
}
}
}
</style>
import type { RouteRecordRaw } from 'vue-router'
import AppLayout from '@/components/layout/Index.vue'
export const routes: Array<RouteRecordRaw> = [
{
path: '/system/llm',
component: AppLayout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
}
]
<script setup lang="ts">
import { ref } from 'vue'
import { ElTabs, ElTabPane } from 'element-plus'
// 移除类型导入
import LLMConfigList from '../components/LLMConfigList.vue'
import LLMConfigForm from '../components/LLMConfigForm.vue'
import SensitiveWordManager from '../components/SensitiveWordManager.vue'
import SensitivePersonManager from '../components/SensitivePersonManager.vue'
import TextProcessConfig from '../components/TextProcessConfig.vue'
// 当前激活的标签页
const activeTab = ref('llm-config')
// 表单相关
const formVisible = ref(false)
const editData = ref<any>()
// 处理编辑
const handleEdit = (data: any) => {
editData.value = data
formVisible.value = true
}
// 处理表单成功
const handleFormSuccess = () => {
formVisible.value = false
editData.value = undefined
// 刷新列表
if (listRef.value) {
listRef.value.refresh()
}
}
// 列表引用
const listRef = ref()
</script>
<template>
<div class="llm-management">
<el-tabs v-model="activeTab" type="border-card" class="management-tabs">
<!-- 大模型配置 -->
<el-tab-pane label="大模型配置" name="llm-config">
<LLMConfigList :on-edit="handleEdit" ref="listRef" />
</el-tab-pane>
<!-- 敏感词管理 -->
<el-tab-pane label="敏感词管理" name="sensitive-word">
<SensitiveWordManager />
</el-tab-pane>
<!-- 敏感人物管理 -->
<el-tab-pane label="敏感人物管理" name="sensitive-person">
<SensitivePersonManager />
</el-tab-pane>
<!-- 文本处理配置 -->
<el-tab-pane label="文本处理配置" name="text-process">
<TextProcessConfig />
</el-tab-pane>
</el-tabs>
<!-- 大模型配置表单 -->
<LLMConfigForm v-model:visible="formVisible" :edit-data="editData" @success="handleFormSuccess" />
</div>
</template>
<style lang="scss" scoped>
.llm-management {
.management-tabs {
min-height: 600px;
:deep(.el-tabs__content) {
padding: 20px;
}
}
}
</style>
...@@ -7,8 +7,8 @@ const httpRequest = axios.create({ ...@@ -7,8 +7,8 @@ const httpRequest = axios.create({
timeout: 60000, timeout: 60000,
withCredentials: true, withCredentials: true,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded',
} },
}) })
// 请求拦截 // 请求拦截
httpRequest.interceptors.request.use( httpRequest.interceptors.request.use(
...@@ -27,7 +27,7 @@ httpRequest.interceptors.request.use( ...@@ -27,7 +27,7 @@ httpRequest.interceptors.request.use(
}, },
function (error) { function (error) {
return Promise.reject(error) return Promise.reject(error)
} },
) )
// 响应拦截 // 响应拦截
...@@ -43,6 +43,14 @@ httpRequest.interceptors.response.use( ...@@ -43,6 +43,14 @@ httpRequest.interceptors.response.use(
ElMessage.error(data.message || data.msg) ElMessage.error(data.message || data.msg)
return Promise.reject(data) return Promise.reject(data)
} }
if (import.meta.env.VITE_STATIC_URL && import.meta.env.MODE === 'school') {
try {
const regex = /(http|https):\/\/(.*?)saas-lab-api/gi
return JSON.parse(JSON.stringify(data).replaceAll(regex, import.meta.env.VITE_STATIC_URL))
} catch (error) {
console.log(error)
}
}
return data return data
}, },
function (error) { function (error) {
...@@ -62,7 +70,7 @@ httpRequest.interceptors.response.use( ...@@ -62,7 +70,7 @@ httpRequest.interceptors.response.use(
console.log(error) console.log(error)
} }
return Promise.reject(error.response || error) return Promise.reject(error.response || error)
} },
) )
export default httpRequest export default httpRequest
// 本地存储工具函数
// 存储键名常量
const STORAGE_KEYS = {
LLM_CONFIGS: 'llm_configs',
SENSITIVE_WORDS: 'sensitive_words',
SENSITIVE_PERSONS: 'sensitive_persons',
TEXT_PROCESS_CONFIG: 'text_process_config',
LLM_FEATURES: 'llm_features',
}
// 生成唯一ID
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// 获取当前时间戳
const getCurrentTime = () => {
return new Date().toISOString()
}
// 通用存储操作
const storage = {
// 获取数据
get<T>(key: string, defaultValue: T): T {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : defaultValue
} catch (error) {
console.error(`Error getting data from localStorage for key ${key}:`, error)
return defaultValue
}
},
// 设置数据
set<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (error) {
console.error(`Error setting data to localStorage for key ${key}:`, error)
}
},
// 删除数据
remove(key: string): void {
try {
localStorage.removeItem(key)
} catch (error) {
console.error(`Error removing data from localStorage for key ${key}:`, error)
}
},
}
// 大模型配置相关操作
export const llmConfigStorage = {
// 获取所有配置
getAll() {
return storage.get(STORAGE_KEYS.LLM_CONFIGS, [] as any[])
},
// 根据ID获取配置
getById(id: string) {
const configs = this.getAll()
return configs.find((config: any) => config.id === id)
},
// 创建配置
create(config: any) {
const configs = this.getAll()
const newConfig = {
...config,
id: generateId(),
created_time: getCurrentTime(),
updated_time: getCurrentTime(),
}
configs.push(newConfig)
storage.set(STORAGE_KEYS.LLM_CONFIGS, configs)
return newConfig
},
// 更新配置
update(id: string, updates: any) {
const configs = this.getAll()
const index = configs.findIndex((config: any) => config.id === id)
if (index !== -1) {
configs[index] = {
...configs[index],
...updates,
updated_time: getCurrentTime(),
}
storage.set(STORAGE_KEYS.LLM_CONFIGS, configs)
return configs[index]
}
return null
},
// 删除配置
delete(id: string) {
const configs = this.getAll()
const filteredConfigs = configs.filter((config: any) => config.id !== id)
storage.set(STORAGE_KEYS.LLM_CONFIGS, filteredConfigs)
return true
},
// 搜索配置
search(params: { name?: string; provider?: string; is_enabled?: string }) {
let configs = this.getAll()
if (params.name) {
configs = configs.filter((config: any) => config.name.toLowerCase().includes(params.name!.toLowerCase()))
}
if (params.provider) {
configs = configs.filter((config: any) => config.provider === params.provider)
}
if (params.is_enabled) {
const enabled = params.is_enabled === 'true'
configs = configs.filter((config: any) => config.is_enabled === enabled)
}
return configs
},
}
// 敏感词相关操作
export const sensitiveWordStorage = {
// 获取所有敏感词
getAll() {
return storage.get(STORAGE_KEYS.SENSITIVE_WORDS, [] as any[])
},
// 根据ID获取敏感词
getById(id: string) {
const words = this.getAll()
return words.find((word: any) => word.id === id)
},
// 创建敏感词
create(word: any) {
const words = this.getAll()
const newWord = {
...word,
id: generateId(),
created_time: getCurrentTime(),
}
words.push(newWord)
storage.set(STORAGE_KEYS.SENSITIVE_WORDS, words)
return newWord
},
// 更新敏感词
update(id: string, updates: any) {
const words = this.getAll()
const index = words.findIndex((word: any) => word.id === id)
if (index !== -1) {
words[index] = { ...words[index], ...updates }
storage.set(STORAGE_KEYS.SENSITIVE_WORDS, words)
return words[index]
}
return null
},
// 删除敏感词
delete(id: string) {
const words = this.getAll()
const filteredWords = words.filter((word: any) => word.id !== id)
storage.set(STORAGE_KEYS.SENSITIVE_WORDS, filteredWords)
return true
},
// 搜索敏感词
search(params: { word?: string; level?: string }) {
let words = this.getAll()
if (params.word) {
words = words.filter((word: any) => word.word.toLowerCase().includes(params.word!.toLowerCase()))
}
if (params.level) {
words = words.filter((word: any) => word.level === params.level)
}
return words
},
// 批量导入
batchImport(words: string[], level: string) {
const existingWords = this.getAll()
const newWords = words.map((word) => ({
id: generateId(),
word: word.trim(),
level,
replacement: '***',
is_enabled: true,
created_time: getCurrentTime(),
}))
const allWords = [...existingWords, ...newWords]
storage.set(STORAGE_KEYS.SENSITIVE_WORDS, allWords)
return newWords
},
}
// 敏感人物相关操作
export const sensitivePersonStorage = {
// 获取所有敏感人物
getAll() {
return storage.get(STORAGE_KEYS.SENSITIVE_PERSONS, [] as any[])
},
// 根据ID获取敏感人物
getById(id: string) {
const persons = this.getAll()
return persons.find((person: any) => person.id === id)
},
// 创建敏感人物
create(person: any) {
const persons = this.getAll()
const newPerson = {
...person,
id: generateId(),
created_time: getCurrentTime(),
}
persons.push(newPerson)
storage.set(STORAGE_KEYS.SENSITIVE_PERSONS, persons)
return newPerson
},
// 更新敏感人物
update(id: string, updates: any) {
const persons = this.getAll()
const index = persons.findIndex((person: any) => person.id === id)
if (index !== -1) {
persons[index] = { ...persons[index], ...updates }
storage.set(STORAGE_KEYS.SENSITIVE_PERSONS, persons)
return persons[index]
}
return null
},
// 删除敏感人物
delete(id: string) {
const persons = this.getAll()
const filteredPersons = persons.filter((person: any) => person.id !== id)
storage.set(STORAGE_KEYS.SENSITIVE_PERSONS, filteredPersons)
return true
},
// 搜索敏感人物
search(params: { name?: string; level?: string }) {
let persons = this.getAll()
if (params.name) {
persons = persons.filter(
(person: any) =>
person.name.toLowerCase().includes(params.name!.toLowerCase()) ||
person.aliases.some((alias: string) => alias.toLowerCase().includes(params.name!.toLowerCase()))
)
}
if (params.level) {
persons = persons.filter((person: any) => person.level === params.level)
}
return persons
},
}
// 文本处理配置相关操作
export const textProcessStorage = {
// 获取配置
get() {
return storage.get(STORAGE_KEYS.TEXT_PROCESS_CONFIG, {
enable_ideology_check: true,
enable_sensitive_word_filter: true,
enable_sensitive_person_filter: true,
auto_replace_sensitive_words: true,
custom_replacement: '***',
})
},
// 更新配置
update(config: any) {
storage.set(STORAGE_KEYS.TEXT_PROCESS_CONFIG, config)
return config
},
}
// 大模型功能配置相关操作
export const llmFeatureStorage = {
// 获取所有功能
getAll() {
return storage.get(STORAGE_KEYS.LLM_FEATURES, [] as any[])
},
// 根据大模型ID获取功能
getByLLMId(llmId: string) {
const features = this.getAll()
return features.filter((feature: any) => feature.llm_id === llmId)
},
// 创建功能
create(feature: any) {
const features = this.getAll()
const newFeature = {
...feature,
id: generateId(),
created_time: getCurrentTime(),
updated_time: getCurrentTime(),
}
features.push(newFeature)
storage.set(STORAGE_KEYS.LLM_FEATURES, features)
return newFeature
},
// 更新功能
update(id: string, updates: any) {
const features = this.getAll()
const index = features.findIndex((feature: any) => feature.id === id)
if (index !== -1) {
features[index] = {
...features[index],
...updates,
updated_time: getCurrentTime(),
}
storage.set(STORAGE_KEYS.LLM_FEATURES, features)
return features[index]
}
return null
},
// 删除功能
delete(id: string) {
const features = this.getAll()
const filteredFeatures = features.filter((feature: any) => feature.id !== id)
storage.set(STORAGE_KEYS.LLM_FEATURES, filteredFeatures)
return true
},
// 获取可用功能模板
getAvailableFeatures() {
return [
{
code: 'text_generation',
name: '文本生成',
description: '基于输入生成文本内容',
},
{
code: 'text_summarization',
name: '文本摘要',
description: '生成文本的摘要内容',
},
{
code: 'text_translation',
name: '文本翻译',
description: '将文本翻译为其他语言',
},
{
code: 'text_analysis',
name: '文本分析',
description: '分析文本的情感、主题等',
},
{
code: 'question_answering',
name: '问答系统',
description: '回答用户提出的问题',
},
]
},
}
// 导出所有存储操作
export default {
llmConfigStorage,
sensitiveWordStorage,
sensitivePersonStorage,
textProcessStorage,
llmFeatureStorage,
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论