Compare commits
15 Commits
Author | SHA1 | Date |
---|---|---|
|
9a75977075 | |
|
183cab7a14 | |
|
df5e27d421 | |
|
b2e6dee7cb | |
|
076758c6fe | |
|
fa493610a2 | |
|
6719fe8600 | |
|
d16e99c63c | |
|
aafd49217c | |
|
226e4d0020 | |
|
9839a4421f | |
|
0b783c70c3 | |
|
873563de73 | |
|
431888797d | |
|
3d11c02067 |
50
package.json
50
package.json
|
@ -9,29 +9,51 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"@codemirror/basic-setup": "^0.20.0",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/lang-cpp": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.3",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/rangeset": "^0.19.9",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"echarts": "^5.5.0",
|
||||
"vue-echarts": "^6.6.0",
|
||||
"three": "^0.160.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@monaco-editor/loader": "^1.5.0",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.6.0",
|
||||
"pinia": "^2.1.0",
|
||||
"dayjs": "^1.11.0",
|
||||
|
||||
|
||||
"echarts": "^5.5.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"monaco-editor": "^0.30.1",
|
||||
"js-cookie": "^3.0.5"
|
||||
"naive-ui": "^2.41.0",
|
||||
"pinia": "^2.1.0",
|
||||
"qs": "^6.14.0",
|
||||
"three": "^0.160.0",
|
||||
"vfonts": "^0.0.3",
|
||||
"vtron": "^0.7.8",
|
||||
"vue": "^3.4.0",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-echarts": "^6.6.0",
|
||||
"vue-monaco-editor": "^0.0.19",
|
||||
"vue-router": "^4.2.0",
|
||||
"vue-windows": "^0.3.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"unplugin-auto-import": "^0.17.0",
|
||||
"unplugin-vue-components": "^0.26.0"
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
10
src/App.vue
10
src/App.vue
|
@ -1,16 +1,18 @@
|
|||
<template>
|
||||
<div class="app">
|
||||
<AppHeader />
|
||||
<AppHeader v-if="!$route.meta.hideNav"/>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
<AppFooter />
|
||||
<AppFooter v-if="!$route.meta.hideNav"/>
|
||||
<AIChatBot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppHeader from './components/AppHeader.vue'
|
||||
import AppFooter from './components/AppFooter.vue'
|
||||
import AIChatBot from '@/components/AIChatBot/AIChatBot.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -18,11 +20,11 @@ import AppFooter from './components/AppFooter.vue'
|
|||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-top: 60px; // 为固定的头部留出空间
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export function chatWithAI(message) {
|
||||
return request({
|
||||
url: '/api/common/testQwen',
|
||||
method: 'get',
|
||||
params: {
|
||||
str1: message
|
||||
}
|
||||
})
|
||||
}
|
|
@ -6,7 +6,8 @@ export function getCourses(params) {
|
|||
}
|
||||
|
||||
export function getCoursesMethod(params) {
|
||||
return get('/bs/courses', params)
|
||||
console.log(params)
|
||||
return get('/bs/courses/homePage', params)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { get, post } from '@/utils/request'
|
||||
|
||||
// 获取用户积分
|
||||
export function getUserPoints() {
|
||||
return get('/api/shop/points')
|
||||
}
|
||||
|
||||
// 获取积分历史
|
||||
export function getPointsHistory() {
|
||||
return get('/api/shop/points/history')
|
||||
}
|
||||
|
||||
// 获取商品列表
|
||||
export function getGoodsList() {
|
||||
return get('/api/shop/goods')
|
||||
}
|
||||
|
||||
// 兑换商品
|
||||
export function exchangeGoods(goodsId) {
|
||||
return post('/api/shop/exchange', { goodsId })
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import {postRequest, post, get, postRequestJSON, getRequest, getRequestWithqs} from "@/utils/request.js";
|
||||
|
||||
|
||||
|
||||
export const testPostUseParamMethod = (param) => {
|
||||
return postRequest("/api/common/testPostUseParam", param)
|
||||
}
|
||||
|
||||
export const testPostUseBodyMethod = (data) =>{
|
||||
return post("/api/common/testPostUseBody", data)
|
||||
}
|
||||
|
||||
export const testGetParamMethod = (param) =>{
|
||||
return get("/api/common/testGetParam", param)
|
||||
}
|
||||
|
||||
export const testGetParamMethodList = (param) =>{
|
||||
return getRequest("/api/common/testGetParam", param)
|
||||
}
|
||||
|
||||
export const testGetParamMethodqs = (param) =>{
|
||||
return getRequestWithqs("/api/common/testGetParam", param)
|
||||
}
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
@ -0,0 +1,438 @@
|
|||
<template>
|
||||
<div class="ai-chat-container">
|
||||
<!-- 悬浮图标 -->
|
||||
<div class="ai-chat-icon" @click="toggleChat" :class="{ 'is-open': showChat }">
|
||||
<el-badge :is-dot="hasNewMessage">
|
||||
<el-button class="chat-button" circle>
|
||||
<el-icon><Service /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
</div>
|
||||
|
||||
<!-- 聊天侧边栏 -->
|
||||
<el-drawer
|
||||
v-model="showChat"
|
||||
title="AI 智能助手"
|
||||
direction="rtl"
|
||||
size="380px"
|
||||
:with-header="false"
|
||||
custom-class="chat-drawer"
|
||||
>
|
||||
<div class="chat-content">
|
||||
<!-- 自定义头部 -->
|
||||
<div class="chat-header">
|
||||
<div class="header-left">
|
||||
<div class="bot-avatar">
|
||||
<el-icon><Service /></el-icon>
|
||||
</div>
|
||||
<div class="bot-info">
|
||||
<h3>AI 智能助手</h3>
|
||||
<span class="status">在线</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button circle class="close-btn" @click="showChat = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" ref="messageContainer">
|
||||
<div v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
:class="['message-wrapper', msg.type]">
|
||||
<div class="avatar" v-if="msg.type === 'bot'">
|
||||
<el-icon><Service /></el-icon>
|
||||
</div>
|
||||
<div class="message">
|
||||
<div class="message-content">{{ msg.content }}</div>
|
||||
<div class="message-time">{{ msg.time || '刚刚' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="inputMessage"
|
||||
placeholder="输入你的问题..."
|
||||
:prefix-icon="ChatLineRound"
|
||||
clearable
|
||||
@keyup.enter="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="sendMessage" :loading="isLoading">
|
||||
<span v-if="!isLoading">发送</span>
|
||||
<el-icon v-else class="is-loading"><Loading /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Service, Position, ChatLineRound, Close, Loading } from '@element-plus/icons-vue'
|
||||
import { chatWithAI } from '@/api/chat'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const showChat = ref(false)
|
||||
const hasNewMessage = ref(false)
|
||||
const inputMessage = ref('')
|
||||
const messageContainer = ref(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const messages = ref([
|
||||
{
|
||||
type: 'bot',
|
||||
content: '你好!我是 AI 助手,很高兴为你服务 👋\n有什么我可以帮你的吗?',
|
||||
time: '现在'
|
||||
}
|
||||
])
|
||||
|
||||
const toggleChat = () => {
|
||||
showChat.value = !showChat.value
|
||||
hasNewMessage.value = false
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
const container = messageContainer.value
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputMessage.value.trim() || isLoading.value) return
|
||||
|
||||
const userMessage = inputMessage.value
|
||||
inputMessage.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
messages.value.push({
|
||||
type: 'user',
|
||||
content: userMessage,
|
||||
time: '刚刚'
|
||||
})
|
||||
|
||||
await scrollToBottom()
|
||||
|
||||
try {
|
||||
const response = await chatWithAI(userMessage)
|
||||
|
||||
if (response.code === 200) {
|
||||
messages.value.push({
|
||||
type: 'bot',
|
||||
content: response.data,
|
||||
time: '刚刚'
|
||||
})
|
||||
} else {
|
||||
throw new Error(response.message || '请求失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('AI 响应出错:' + (error.message || '未知错误'))
|
||||
messages.value.push({
|
||||
type: 'bot',
|
||||
content: '抱歉,我遇到了一些问题,请稍后再试。',
|
||||
time: '刚刚'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
await scrollToBottom()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-chat-container {
|
||||
position: fixed;
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.chat-button {
|
||||
background: #409eff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: inherit;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.chat-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
.chat-button .el-icon {
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-button :deep(svg) {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-chat-icon {
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0 2px 8px rgba(64, 158, 255, 0.25));
|
||||
}
|
||||
|
||||
.ai-chat-icon.is-open .chat-button {
|
||||
transform: rotate(360deg);
|
||||
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.chat-drawer :deep(.el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 16px 20px;
|
||||
background: #ffffff;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #409eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bot-avatar .el-icon {
|
||||
color: white;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.bot-info h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: #67c23a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #67c23a;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #e4e7ed;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #f4f4f5;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.message-wrapper.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #409eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar .el-icon {
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
max-width: 80%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bot .message {
|
||||
background-color: #f4f4f5;
|
||||
color: #303133;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
.user .message {
|
||||
background-color: #409eff;
|
||||
color: white;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.user .message-time {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 16px;
|
||||
background-color: white;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px #e4e7ed inset !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px #409eff inset !important;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary) {
|
||||
background-color: #409eff;
|
||||
border-color: #409eff;
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.chat-drawer {
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb {
|
||||
background-color: #e4e7ed;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 优化加载状态样式 */
|
||||
:deep(.is-loading) {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加红点样式 */
|
||||
:deep(.el-badge__content.is-dot) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
right: 3px;
|
||||
top: 3px;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px #ff4d4f;
|
||||
}
|
||||
</style>
|
|
@ -7,7 +7,7 @@
|
|||
<span>云帆教育</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
|
||||
<nav class="main-nav">
|
||||
<router-link to="/">首页</router-link>
|
||||
<router-link to="/course">课程中心</router-link>
|
||||
|
@ -17,7 +17,7 @@
|
|||
<router-link to="/mall">积分商城</router-link>
|
||||
<router-link to="/announcement">公告</router-link>
|
||||
</nav>
|
||||
|
||||
|
||||
<div class="user-actions">
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<!-- 管理员入口 -->
|
||||
|
@ -25,7 +25,7 @@
|
|||
<el-icon><Setting /></el-icon>
|
||||
<span>管理后台</span>
|
||||
</router-link>
|
||||
|
||||
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="userStore.userInfo.avatar" />
|
||||
|
@ -41,10 +41,10 @@
|
|||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
|
||||
<template v-else>
|
||||
<router-link to="/login" class="login-btn">登录</router-link>
|
||||
<!-- <router-link to="/register" class="register-btn">注册</router-link>-->
|
||||
<router-link to="/register" class="register-btn">注册</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -80,7 +80,7 @@ const handleCommand = (command) => {
|
|||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
|
||||
|
||||
.header-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
@ -89,40 +89,40 @@ const handleCommand = (command) => {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
|
||||
|
||||
.logo {
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
|
||||
|
||||
img {
|
||||
height: 36px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
span {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
padding: 5px 0;
|
||||
position: relative;
|
||||
|
||||
|
||||
&.router-link-active {
|
||||
color: #409EFF;
|
||||
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
@ -133,18 +133,18 @@ const handleCommand = (command) => {
|
|||
background-color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
|
||||
|
||||
.admin-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -154,41 +154,41 @@ const handleCommand = (command) => {
|
|||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
span {
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.login-btn, .register-btn {
|
||||
text-decoration: none;
|
||||
padding: 6px 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
.login-btn {
|
||||
color: #409EFF;
|
||||
border: 1px solid #409EFF;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.register-btn {
|
||||
background-color: #409EFF;
|
||||
color: white;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: #66b1ff;
|
||||
}
|
||||
|
@ -196,4 +196,4 @@ const handleCommand = (command) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
|
@ -0,0 +1,192 @@
|
|||
<template>
|
||||
<div class="editor-container">
|
||||
<div ref="editor" class="code-editor"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onBeforeUnmount, defineProps, defineEmits } from "vue";
|
||||
import { EditorView, keymap, gutter, GutterMarker, lineNumbers } from "@codemirror/view";
|
||||
import { EditorState, StateField, StateEffect } from "@codemirror/state";
|
||||
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { RangeSet } from "@codemirror/rangeset";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
|
||||
class BreakpointMarker extends GutterMarker {
|
||||
toDOM() {
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "breakpoint-marker";
|
||||
marker.textContent = "●";
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "CodeEditor",
|
||||
props: {
|
||||
modelValue: String, // 双向绑定代码内容
|
||||
language: String, // 代码语言,默认 JS
|
||||
height: {
|
||||
type: String,
|
||||
default: '600px'
|
||||
}
|
||||
},
|
||||
emits: ["update:modelValue"], // 监听代码变更
|
||||
setup(props, { emit }) {
|
||||
const editor = ref(null);
|
||||
let editorView = null;
|
||||
|
||||
const setBreakpoint = StateEffect.define();
|
||||
const breakpointsField = StateField.define({
|
||||
create: () => new Set(),
|
||||
update(breakpoints, tr) {
|
||||
const newBreakpoints = new Set(breakpoints);
|
||||
for (let effect of tr.effects) {
|
||||
if (effect.is(setBreakpoint)) {
|
||||
const line = effect.value;
|
||||
if (newBreakpoints.has(line)) {
|
||||
newBreakpoints.delete(line);
|
||||
} else {
|
||||
newBreakpoints.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBreakpoints;
|
||||
},
|
||||
});
|
||||
|
||||
const breakpointGutter = gutter({
|
||||
class: "cm-breakpoint-gutter",
|
||||
markers: (view) => {
|
||||
const breakpoints = view.state.field(breakpointsField);
|
||||
const markers = [];
|
||||
for (const line of breakpoints) {
|
||||
try {
|
||||
const lineInfo = view.state.doc.line(line);
|
||||
markers.push(new BreakpointMarker().range(lineInfo.from));
|
||||
} catch (e) {
|
||||
console.warn("Invalid line number skipped:", line);
|
||||
}
|
||||
}
|
||||
return RangeSet.of(markers, true);
|
||||
},
|
||||
domEventHandlers: {
|
||||
click: (view, block) => {
|
||||
const lineNumber = view.state.doc.lineAt(block.from).number;
|
||||
view.dispatch({
|
||||
effects: [setBreakpoint.of(lineNumber)],
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!editor.value) return;
|
||||
|
||||
editorView = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: props.modelValue || "",
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
breakpointGutter,
|
||||
breakpointsField,
|
||||
javascript(),
|
||||
oneDark,
|
||||
indentUnit.of(" "), // 使用4个空格作为缩进
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
indentWithTab
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
emit("update:modelValue", update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
fontSize: "14px",
|
||||
height: "100%"
|
||||
},
|
||||
".cm-content": {
|
||||
fontFamily: "'Fira Code', monospace",
|
||||
padding: "12px 0",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 12px",
|
||||
lineHeight: "1.6"
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#666",
|
||||
border: "none"
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#333"
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "rgba(255,255,255,0.03)"
|
||||
}
|
||||
}),
|
||||
EditorView.baseTheme({
|
||||
".cm-breakpoint-gutter": {
|
||||
width: "30px",
|
||||
backgroundColor: "#1e1e1e",
|
||||
borderRight: "1px solid #333"
|
||||
},
|
||||
".breakpoint-marker": {
|
||||
color: "#ff5555",
|
||||
fontSize: "14px",
|
||||
cursor: "pointer",
|
||||
textAlign: "center"
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
parent: editor.value,
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorView?.destroy();
|
||||
});
|
||||
|
||||
return { editor };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.editor-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #282c34;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.code-editor ::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.code-editor ::-webkit-scrollbar-track {
|
||||
background: #21252b;
|
||||
}
|
||||
|
||||
.code-editor ::-webkit-scrollbar-thumb {
|
||||
background: #454c59;
|
||||
border-radius: 6px;
|
||||
border: 3px solid #21252b;
|
||||
}
|
||||
|
||||
.code-editor ::-webkit-scrollbar-thumb:hover {
|
||||
background: #535b6a;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,393 @@
|
|||
<template>
|
||||
<div class="debugger-container">
|
||||
<div class="debugger-toolbar">
|
||||
<el-button-group>
|
||||
<el-button type="primary" size="small" @click="startDebug" :disabled="isDebugging">
|
||||
<el-icon><VideoPlay /></el-icon> 开始调试
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="continueExecution" :disabled="!isDebugging">
|
||||
<el-icon><Right /></el-icon> 继续
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="stepOver" :disabled="!isDebugging">
|
||||
<el-icon><Bottom /></el-icon> 单步跳过
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="stepInto" :disabled="!isDebugging">
|
||||
<el-icon><TopRight /></el-icon> 单步进入
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="stepOut" :disabled="!isDebugging">
|
||||
<el-icon><TopLeft /></el-icon> 单步跳出
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="stopDebug" :disabled="!isDebugging">
|
||||
<el-icon><VideoPause /></el-icon> 停止调试
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div class="debugger-content">
|
||||
<div class="debugger-code">
|
||||
<code-editor
|
||||
v-model="code"
|
||||
:language="language"
|
||||
:readOnly="isDebugging"
|
||||
height="400px"
|
||||
@line-click="toggleBreakpoint"
|
||||
/>
|
||||
<div
|
||||
v-for="(bp, index) in breakpoints"
|
||||
:key="index"
|
||||
class="breakpoint"
|
||||
:style="{ top: (bp.line * 18) + 'px' }"
|
||||
></div>
|
||||
<div
|
||||
v-if="currentLine"
|
||||
class="current-line"
|
||||
:style="{ top: (currentLine * 18) + 'px' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="debugger-panels">
|
||||
<el-tabs v-model="activePanel">
|
||||
<el-tab-pane label="控制台" name="console">
|
||||
<div class="console-output" ref="consoleOutputEl">
|
||||
<div v-for="(line, index) in consoleOutput" :key="index" class="console-line">
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="console-input">
|
||||
<el-input
|
||||
v-model="consoleInput"
|
||||
placeholder="输入GDB命令..."
|
||||
@keyup.enter="executeCommand"
|
||||
:disabled="!isDebugging"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="executeCommand" :disabled="!isDebugging">执行</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="变量" name="variables">
|
||||
<el-table :data="variables" style="width: 100%">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="value" label="值" />
|
||||
<el-table-column prop="type" label="类型" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="调用栈" name="callstack">
|
||||
<el-table :data="callstack" style="width: 100%">
|
||||
<el-table-column prop="level" label="层级" width="60" />
|
||||
<el-table-column prop="function" label="函数" />
|
||||
<el-table-column prop="location" label="位置" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { VideoPlay, VideoPause, Right, Bottom, TopRight, TopLeft } from '@element-plus/icons-vue'
|
||||
import CodeEditor from '@/components/CodeEditor/index.vue'
|
||||
import { startDebugSession, executeDebugCommand, getDebugStatus, endDebugSession } from '@/api/exam/exam'
|
||||
|
||||
export default {
|
||||
name: 'Debugger',
|
||||
components: {
|
||||
CodeEditor,
|
||||
VideoPlay, VideoPause, Right, Bottom, TopRight, TopLeft
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'cpp'
|
||||
},
|
||||
problemId: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const code = ref(props.modelValue)
|
||||
const isDebugging = ref(false)
|
||||
const sessionId = ref(null)
|
||||
const breakpoints = ref([])
|
||||
const currentLine = ref(null)
|
||||
const consoleOutput = ref([])
|
||||
const consoleInput = ref('')
|
||||
const activePanel = ref('console')
|
||||
const variables = ref([])
|
||||
const callstack = ref([])
|
||||
const consoleOutputEl = ref(null)
|
||||
const statusInterval = ref(null)
|
||||
|
||||
// 监听代码变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (code.value !== newVal) {
|
||||
code.value = newVal
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => code.value, (newVal) => {
|
||||
if (props.modelValue !== newVal) {
|
||||
emit('update:modelValue', newVal)
|
||||
}
|
||||
})
|
||||
|
||||
// 开始调试
|
||||
const startDebug = async () => {
|
||||
try {
|
||||
const response = await startDebugSession({
|
||||
code: code.value,
|
||||
language: props.language,
|
||||
problemId: props.problemId,
|
||||
breakpoints: breakpoints.value.map(bp => bp.line)
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
sessionId.value = response.data.sessionId
|
||||
isDebugging.value = true
|
||||
consoleOutput.value = ['调试会话已启动...']
|
||||
|
||||
// 开始定期获取调试状态
|
||||
statusInterval.value = setInterval(updateDebugStatus, 1000)
|
||||
|
||||
ElMessage.success('调试会话已启动')
|
||||
} else {
|
||||
ElMessage.error(response.msg || '启动调试失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('启动调试出错:', error)
|
||||
ElMessage.error('启动调试出错')
|
||||
}
|
||||
}
|
||||
|
||||
// 停止调试
|
||||
const stopDebug = async () => {
|
||||
if (!sessionId.value) return
|
||||
|
||||
try {
|
||||
await endDebugSession(sessionId.value)
|
||||
clearInterval(statusInterval.value)
|
||||
isDebugging.value = false
|
||||
sessionId.value = null
|
||||
currentLine.value = null
|
||||
variables.value = []
|
||||
callstack.value = []
|
||||
consoleOutput.value.push('调试会话已结束')
|
||||
ElMessage.info('调试会话已结束')
|
||||
} catch (error) {
|
||||
console.error('结束调试出错:', error)
|
||||
ElMessage.error('结束调试出错')
|
||||
}
|
||||
}
|
||||
|
||||
// 执行GDB命令
|
||||
const executeCommand = async () => {
|
||||
if (!sessionId.value || !consoleInput.value) return
|
||||
|
||||
try {
|
||||
consoleOutput.value.push(`> ${consoleInput.value}`)
|
||||
|
||||
const response = await executeDebugCommand(sessionId.value, consoleInput.value)
|
||||
|
||||
if (response.code === 200) {
|
||||
if (response.data.output) {
|
||||
consoleOutput.value.push(response.data.output)
|
||||
}
|
||||
|
||||
// 更新调试状态
|
||||
updateDebugStatus()
|
||||
} else {
|
||||
consoleOutput.value.push(`错误: ${response.msg || '命令执行失败'}`)
|
||||
}
|
||||
|
||||
consoleInput.value = ''
|
||||
|
||||
// 滚动到控制台底部
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
console.error('执行命令出错:', error)
|
||||
consoleOutput.value.push(`错误: ${error.message || '命令执行出错'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新调试状态
|
||||
const updateDebugStatus = async () => {
|
||||
if (!sessionId.value) return
|
||||
|
||||
try {
|
||||
const response = await getDebugStatus(sessionId.value)
|
||||
|
||||
if (response.code === 200) {
|
||||
const { currentLine: line, variables: vars, callstack: stack } = response.data
|
||||
|
||||
currentLine.value = line
|
||||
variables.value = vars || []
|
||||
callstack.value = stack || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取调试状态出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换断点
|
||||
const toggleBreakpoint = (line) => {
|
||||
const index = breakpoints.value.findIndex(bp => bp.line === line)
|
||||
|
||||
if (index >= 0) {
|
||||
breakpoints.value.splice(index, 1)
|
||||
} else {
|
||||
breakpoints.value.push({ line })
|
||||
}
|
||||
|
||||
// 如果正在调试,更新断点
|
||||
if (isDebugging.value) {
|
||||
executeCommand(`break ${line}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 继续执行
|
||||
const continueExecution = () => {
|
||||
executeCommand('continue')
|
||||
}
|
||||
|
||||
// 单步跳过
|
||||
const stepOver = () => {
|
||||
executeCommand('next')
|
||||
}
|
||||
|
||||
// 单步进入
|
||||
const stepInto = () => {
|
||||
executeCommand('step')
|
||||
}
|
||||
|
||||
// 单步跳出
|
||||
const stepOut = () => {
|
||||
executeCommand('finish')
|
||||
}
|
||||
|
||||
// 滚动到控制台底部
|
||||
const scrollToBottom = () => {
|
||||
if (consoleOutputEl.value) {
|
||||
consoleOutputEl.value.scrollTop = consoleOutputEl.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载前清理
|
||||
onBeforeUnmount(() => {
|
||||
if (statusInterval.value) {
|
||||
clearInterval(statusInterval.value)
|
||||
}
|
||||
|
||||
if (sessionId.value) {
|
||||
endDebugSession(sessionId.value).catch(console.error)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
code,
|
||||
isDebugging,
|
||||
breakpoints,
|
||||
currentLine,
|
||||
consoleOutput,
|
||||
consoleOutputEl,
|
||||
consoleInput,
|
||||
activePanel,
|
||||
variables,
|
||||
callstack,
|
||||
startDebug,
|
||||
stopDebug,
|
||||
executeCommand,
|
||||
toggleBreakpoint,
|
||||
continueExecution,
|
||||
stepOver,
|
||||
stepInto,
|
||||
stepOut
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.debugger-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.debugger-toolbar {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.debugger-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debugger-code {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.breakpoint {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #f56c6c;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.current-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 18px;
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
border-left: 2px solid #409eff;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.debugger-panels {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.console-output {
|
||||
height: calc(100% - 40px);
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.console-line {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.console-input {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #dcdfe6;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,383 @@
|
|||
<template>
|
||||
<div class="echarts-wrapper">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-mask">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="chartRef"
|
||||
:style="{
|
||||
width: width,
|
||||
height: height,
|
||||
minHeight: '100px'
|
||||
}"
|
||||
class="echarts-container"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps({
|
||||
// 图表数据
|
||||
data: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// 图表类型配置
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
// 标题
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 图表主题
|
||||
theme: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 图表宽度
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
// 图表高度
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
// 是否自动调整大小
|
||||
autoResize: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 加载状态
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 动画时长
|
||||
animationDuration: {
|
||||
type: Number,
|
||||
default: 1000
|
||||
},
|
||||
// 是否平滑曲线(用于折线图)
|
||||
smooth: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 图表类型
|
||||
chartType: {
|
||||
type: String,
|
||||
default: 'line',
|
||||
validator: (value) => ['line', 'bar', 'pie', 'scatter'].includes(value)
|
||||
},
|
||||
// tooltip 显示的系列名称数组,为空则显示全部
|
||||
tooltipSeries: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 自定义 tooltip 格式化函数
|
||||
tooltipFormatter: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Emits 定义
|
||||
const emit = defineEmits(['chartReady', 'click', 'legendselectchanged'])
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
// 默认配置
|
||||
const defaultConfig = {
|
||||
xField: 'name',
|
||||
series: [
|
||||
{
|
||||
name: '数值',
|
||||
field: 'value',
|
||||
type: 'bar'
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
name: '',
|
||||
min: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
const mergedConfig = computed(() => ({
|
||||
...defaultConfig,
|
||||
...props.config
|
||||
}))
|
||||
|
||||
// 生成图表配置
|
||||
const generateOptions = computed(() => {
|
||||
if (!props.data?.length) return {}
|
||||
|
||||
const config = mergedConfig.value
|
||||
const categories = props.data.map(item => item[config.xField])
|
||||
|
||||
// 处理系列数据
|
||||
const series = config.series
|
||||
.filter(s => s.show !== false) // 过滤掉 show: false 的系列
|
||||
.map(s => ({
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
yAxisIndex: s.yAxisIndex || 0,
|
||||
data: props.data.map(item => item[s.field]),
|
||||
color: s.color,
|
||||
barMaxWidth: 50,
|
||||
barGap: '30%',
|
||||
itemStyle: {
|
||||
color: s.color,
|
||||
borderRadius: s.type === 'bar' ? [4, 4, 0, 0] : 0
|
||||
},
|
||||
label: s.showLabel ? {
|
||||
show: true,
|
||||
position: s.type === 'line' ? 'top' : 'inside',
|
||||
formatter: s.labelFormatter || '{c}',
|
||||
fontSize: 12,
|
||||
color: s.type === 'line' ? s.color : '#fff'
|
||||
} : undefined,
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
}
|
||||
}))
|
||||
|
||||
const dataZoom = [
|
||||
{
|
||||
type: 'slider', // 滑动条类型
|
||||
xAxisIndex: 0, // 绑定到 X 轴
|
||||
filterMode: 'filter', // 过滤模式
|
||||
start: 0, // 初始左侧位置(百分比)
|
||||
end: 100, // 初始右侧位置(百分比)
|
||||
height: 30, // 高度
|
||||
bottom: 10, // 距离底部的距离
|
||||
handleSize: '80%',
|
||||
handleStyle: {
|
||||
color: '#fff',
|
||||
shadowBlur: 3,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.6)',
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'inside', // 内置类型(鼠标滚轮)
|
||||
xAxisIndex: 0,
|
||||
filterMode: 'filter'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
return {
|
||||
title: props.title ? {
|
||||
text: props.title
|
||||
} : null,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
},
|
||||
formatter: (params) => {
|
||||
// 获取所有系列的配置
|
||||
const allSeries = config.series;
|
||||
// 获取当前鼠标所在的类目轴值(例如,'手机')
|
||||
const currentAxisValue = params[0].axisValue;
|
||||
|
||||
// 找到当前类目轴值对应的所有系列的数据
|
||||
const dataMap = {};
|
||||
props.data.forEach(item => {
|
||||
if (item[config.xField] === currentAxisValue) {
|
||||
allSeries.forEach(series => {
|
||||
dataMap[series.name] = item[series.field];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 构造 tooltip 内容
|
||||
let result = `${currentAxisValue}<br/>`;
|
||||
allSeries.forEach(series => {
|
||||
|
||||
// 只有 tooltip.show 为 true 的系列才显示在 tooltip 中
|
||||
if (series.tooltip && series.tooltip.show === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取系列的颜色
|
||||
const seriesColor = series.color || '#000';
|
||||
// 获取系列的值
|
||||
const seriesValue = dataMap[series.name];
|
||||
// 获取系列的单位(从 labelFormatter 中提取)
|
||||
let unit = '';
|
||||
if (series.labelFormatter) {
|
||||
const match = series.labelFormatter.match(/\{c\}(.+)/);
|
||||
if (match) {
|
||||
unit = match[1];
|
||||
}
|
||||
}
|
||||
// 添加到 tooltip 内容中
|
||||
result += `<div style="display: flex; align-items: center;">
|
||||
<span style="display: inline-block; width: 10px; height: 10px; background-color: ${seriesColor}; margin-right: 5px;"></span>
|
||||
${series.name}: ${seriesValue}${unit}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
data: config.series
|
||||
.filter(s => s.legendShow !== false)
|
||||
.map(s => s.name)
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: categories.length > 5 ? 30 : 0
|
||||
}
|
||||
},
|
||||
yAxis: config.yAxis.map((axis, index) => ({
|
||||
type: 'value',
|
||||
name: axis.name,
|
||||
min: axis.min,
|
||||
max: axis.max,
|
||||
position: index === 0 ? 'left' : 'right',
|
||||
splitLine: {
|
||||
show: index === 0
|
||||
},
|
||||
axisLabel: axis.axisLabel ? {
|
||||
formatter: axis.axisLabel
|
||||
} : undefined
|
||||
})),
|
||||
series,
|
||||
grid: {
|
||||
top: '15%',
|
||||
bottom: '15%',
|
||||
left: '10%',
|
||||
right: '10%',
|
||||
containLabel: true
|
||||
},
|
||||
dataZoom: dataZoom
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化图表
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value, props.theme)
|
||||
chartInstance.setOption(generateOptions.value)
|
||||
emit('chartReady', chartInstance)
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
chartInstance.setOption(generateOptions.value)
|
||||
}
|
||||
|
||||
// 调整图表大小
|
||||
const resizeChart = debounce(() => {
|
||||
if (!chartInstance) return
|
||||
chartInstance.resize()
|
||||
}, 100)
|
||||
|
||||
// 监听配置变化
|
||||
watch(
|
||||
() => [props.data, props.config],
|
||||
() => updateChart(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 监听主题变化
|
||||
watch(
|
||||
() => props.theme,
|
||||
() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
}
|
||||
initChart()
|
||||
}
|
||||
)
|
||||
|
||||
// 监听加载状态
|
||||
watch(
|
||||
() => props.loading,
|
||||
(val) => {
|
||||
if (chartInstance) {
|
||||
val ? chartInstance.showLoading() : chartInstance.hideLoading()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
if (props.autoResize) {
|
||||
window.addEventListener('resize', resizeChart)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.autoResize) {
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
}
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.echarts-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.echarts-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
|
@ -6,9 +6,7 @@ import ElementPlus from 'element-plus'
|
|||
import 'element-plus/dist/index.css'
|
||||
import './styles/element.scss'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
// 暂时注释掉这两行,直到我们创建了相应的文件
|
||||
// import 'animate.css'
|
||||
// import './assets/styles/main.scss'
|
||||
import 'vtron/distlib/style.css';
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
@ -21,4 +19,4 @@ app.use(createPinia())
|
|||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
|
|
|
@ -67,7 +67,16 @@ const routes = [
|
|||
path: '/exam/start/:id',
|
||||
component: () => import('../views/paper/exam/exam.vue'),
|
||||
name: 'StartExam',
|
||||
meta: { title: '开始考试' },
|
||||
|
||||
meta: {
|
||||
|
||||
title: '开始考试',
|
||||
|
||||
isExam: true,
|
||||
|
||||
hideNav: true
|
||||
|
||||
},
|
||||
hidden: true
|
||||
},
|
||||
|
||||
|
@ -87,6 +96,11 @@ const routes = [
|
|||
name: 'ActivityCenter',
|
||||
component: () => import('../views/activity/ActivityCenter.vue')
|
||||
},
|
||||
{
|
||||
path: '/test',
|
||||
name: 'TestCenter',
|
||||
component: () => import('../views/activity/TestCenter.vue')
|
||||
},
|
||||
{
|
||||
path: '/mall',
|
||||
name: 'Mall',
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import axios from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import qs from 'qs';
|
||||
|
||||
// 创建 axios 实例
|
||||
const service = axios.create({
|
||||
|
@ -23,7 +24,7 @@ service.interceptors.request.use(
|
|||
|
||||
|
||||
|
||||
config.headers['token'] = `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NDE3ODAxNDMsInVzZXJuYW1lIjoiYWRtaW4ifQ._jKmdu1T-zpf_qSWRTBtovJ51v2ONC6CGF-60MLJOOE`
|
||||
config.headers['token'] = `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NDI0NzI5MzUsInVzZXJuYW1lIjoiYWRtaW4ifQ.-4e5HlHyM9gYlfkaraNdIcPDAeegiPoe3KPRaID9fRY`
|
||||
|
||||
|
||||
// 根据请求方法和数据类型动态设置 Content-Type
|
||||
|
@ -50,6 +51,7 @@ service.interceptors.request.use(
|
|||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
|
||||
const res = response.data
|
||||
|
||||
// 根据实际后端返回的状态码和数据结构调整这里的逻辑
|
||||
|
@ -206,6 +208,65 @@ export function del(url, params) {
|
|||
})
|
||||
}
|
||||
|
||||
// 封装 `@RequestParam` 方式的 POST 请求
|
||||
export function postRequest(url, params) {
|
||||
const formData = new FormData();
|
||||
for (const key in params) {
|
||||
console.log(params[key]);
|
||||
formData.append(key, params[key]);
|
||||
}
|
||||
for (let pair of formData.entries()) {
|
||||
console.log(pair[0] + ': ' + pair[1]);
|
||||
}
|
||||
return service({
|
||||
url,
|
||||
method: 'post',
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function getRequest(url, params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const key in params) {
|
||||
if (Array.isArray(params[key])) {
|
||||
params[key].forEach(item => {
|
||||
searchParams.append(key, item); // 注意这里直接使用 key,不加 []
|
||||
});
|
||||
} else {
|
||||
searchParams.append(key, params[key]);
|
||||
}
|
||||
}
|
||||
return service({
|
||||
url: `${url}?${searchParams.toString()}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function getRequestWithqs(url, params) {
|
||||
return service({
|
||||
url,
|
||||
method: 'get',
|
||||
params,
|
||||
paramsSerializer: params => {
|
||||
// 设置 arrayFormat 为 'repeat' 表示 ids=1&ids=2&ids=3
|
||||
// 设置 arrayFormat 为 'comma' 表示 ds=1%2C2%2C3
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function postRequestJSON(url, params) {
|
||||
return service({
|
||||
url,
|
||||
method: 'post',
|
||||
data: JSON.stringify(params), // 发送 JSON
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// 封装上传文件的请求
|
||||
export function upload(url, file, onUploadProgress) {
|
||||
const formData = new FormData()
|
||||
|
|
|
@ -170,11 +170,18 @@ const features = [
|
|||
|
||||
const popularCourses = ref([])
|
||||
|
||||
|
||||
|
||||
const getCourses = async () => {
|
||||
var axiosResponse = await getCoursesMethod();
|
||||
|
||||
const params = {
|
||||
page: 1, // 当前页码
|
||||
size: 4, // 每页数量
|
||||
};
|
||||
|
||||
var axiosResponse = await getCoursesMethod(params);
|
||||
console.log(axiosResponse)
|
||||
popularCourses.value = axiosResponse.data
|
||||
|
||||
}
|
||||
|
||||
const viewCourse = (courseId) => {
|
||||
|
|
|
@ -1,11 +1,174 @@
|
|||
<template>
|
||||
<div class="activity-center">
|
||||
<h1 class="page-title">活动大厅</h1>
|
||||
<p>活动大厅功能正在开发中,敬请期待...</p>
|
||||
</div>
|
||||
<el-tree-select
|
||||
v-model="selectedValue"
|
||||
:data="treeData"
|
||||
:props="treeProps"
|
||||
show-checkbox
|
||||
@check="handleCheck"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
testGetParamMethod,
|
||||
testGetParamMethodList,
|
||||
testGetParamMethodqs,
|
||||
testPostUseBodyMethod,
|
||||
testPostUseParamMethod
|
||||
} from "@/api/test.js";
|
||||
import { ref, reactive, getCurrentInstance, nextTick, onMounted} from 'vue';
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
|
||||
onMounted(() => {
|
||||
console.log(111)
|
||||
// 方式1 post方法 @RequestParam("ids") List<String> ids
|
||||
// 可以
|
||||
// Spring 内部有个转换机制,会自动把逗号分隔的字符串拆分成 List
|
||||
/* testPostUseParamMethod({
|
||||
ids: "122ss,22s2,3ss11aa",
|
||||
str: 1
|
||||
})*/
|
||||
//可以
|
||||
// 当你直接将数组作为一个值传入 FormData 或 URLSearchParams 的append 方法时,
|
||||
// JavaScript 会自动将数组转换为字符串,默认行为是调用数组的 toString() 方法,
|
||||
// 这个方法会将数组元素以逗号分隔拼接成一个字符串。
|
||||
|
||||
//方式二 post方法 @RequestBody 实体内部 List<String> ids;
|
||||
|
||||
//可以
|
||||
// testPostUseParamMethod({
|
||||
// ids: [1, 2, 3],
|
||||
// str: 1
|
||||
// })
|
||||
//可以
|
||||
// testPostUseBodyMethod({
|
||||
// ids: [1, 2, 3],
|
||||
// str: 1
|
||||
// })
|
||||
// 不可以
|
||||
// 在反序列化 JSON 时,试图将一个字符串(例如:"122ss,22s2,3ss11aa")转换成一个 ArrayList 类型,
|
||||
// 但它找不到合适的构造函数或工厂方法来从单个字符串创建一个 List 对象。
|
||||
/* testPostUseBodyMethod({
|
||||
ids: "122ss,22s2,3ss11aa",
|
||||
str: 1
|
||||
})*/
|
||||
// get 方法 @RequestParam("ids") List<String> ids
|
||||
// ids = 122ss,22s2,3ss11a
|
||||
testGetParamMethod({
|
||||
ids: "122ss,22s2,3ss11aa",
|
||||
str: 1
|
||||
})
|
||||
//不行会变成 ids[]=1&ids[]=2&ids[]=3&str=1
|
||||
/* testGetParamMethod({
|
||||
ids: [1, 2, 3],
|
||||
str: 1
|
||||
})*/
|
||||
|
||||
//手动处理
|
||||
//ids=1&ids=2&ids=3
|
||||
testGetParamMethodList({
|
||||
ids: [1, 2, 3],
|
||||
str: 1
|
||||
})
|
||||
|
||||
//ids=1&ids=2&ids=3
|
||||
testGetParamMethodqs({
|
||||
ids: [1, 2, 3],
|
||||
str: 1
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
||||
|
||||
// 树形数据
|
||||
const treeData = ref([
|
||||
{
|
||||
id: 1,
|
||||
label: '父级1',
|
||||
children: [
|
||||
{ id: 11, label: '子级1-1' },
|
||||
{ id: 12, label: '子级1-2' },
|
||||
{
|
||||
id: 13,
|
||||
label: '子级1-3',
|
||||
children: [
|
||||
{ id: 131, label: '子子级1-3-1' },
|
||||
{ id: 132, label: '子子级1-3-2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '父级2',
|
||||
children: [
|
||||
{ id: 21, label: '子级2-1' },
|
||||
{ id: 22, label: '子级2-2' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// 树形选择的配置属性
|
||||
const treeProps = {
|
||||
value: 'id',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
};
|
||||
|
||||
// 当前选中的值
|
||||
const selectedValue = ref(null);
|
||||
|
||||
// 处理节点选中事件
|
||||
const handleCheck = (currentNode, checkedNodes) => {
|
||||
// 获取所有选中的节点
|
||||
const checked = checkedNodes.checkedNodes;
|
||||
|
||||
// 用于存储最终的叶子节点id
|
||||
const finalIds = new Set(); // 使用Set避免重复
|
||||
|
||||
checked.forEach(node => {
|
||||
// 判断是否是叶子节点(即没有子节点)
|
||||
if (!node.children || node.children.length === 0) {
|
||||
//在这 可以 node.属性 去个性化判断
|
||||
finalIds.add(node.id);
|
||||
} else {
|
||||
// 如果是父级节点,递归获取其下的叶子节点id
|
||||
const leafIds = getLeafIds(node, new Set());
|
||||
leafIds.forEach(id => finalIds.add(id));
|
||||
}
|
||||
});
|
||||
|
||||
console.log('最终的叶子节点id列表:', Array.from(finalIds));
|
||||
};
|
||||
|
||||
// 递归获取叶子节点id
|
||||
const getLeafIds = (node, visited) => {
|
||||
const ids = new Set();
|
||||
if (node.children) {
|
||||
node.children.forEach(child => {
|
||||
if (!child.children || child.children.length === 0) {
|
||||
//同理这里 //在这 可以 child.属性 去个性化判断
|
||||
// 如果是叶子节点,添加其id
|
||||
ids.add(child.id);
|
||||
} else {
|
||||
// 如果不是叶子节点,继续递归
|
||||
if (!visited.has(child.id)) {
|
||||
visited.add(child.id);
|
||||
const childIds = getLeafIds(child, visited);
|
||||
childIds.forEach(id => ids.add(id));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -13,4 +176,4 @@
|
|||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,409 @@
|
|||
<template>
|
||||
<div class="test-center">
|
||||
<!-- 第一行搜索区域 -->
|
||||
<div class="search-section">
|
||||
<el-tree-select
|
||||
v-model="selectedValue"
|
||||
:data="treeData"
|
||||
:props="treeProps"
|
||||
show-checkbox
|
||||
@check="handleCheck"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 第二行操作区域 -->
|
||||
<div class="operation-section">
|
||||
<div class="left-area">
|
||||
<el-select v-model="value" placeholder="Select" style="width: 240px">
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-tree-select
|
||||
v-model="selectedValue"
|
||||
:data="treeData"
|
||||
:props="treeProps"
|
||||
show-checkbox
|
||||
@check="handleCheck"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</div>
|
||||
<div class="right-area">
|
||||
<el-button type="primary" disabled>操作1</el-button>
|
||||
<el-button type="success" disabled>操作2</el-button>
|
||||
<el-button type="warning" disabled>操作3</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<div class="table-section">
|
||||
<el-table :data="tableData" style="width: 100%" @cell-click="handleCellClick">
|
||||
<el-table-column prop="date" label="Date" width="180" />
|
||||
<el-table-column prop="name" label="Name" width="180" />
|
||||
<el-table-column prop="address" label="Address" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页区域 -->
|
||||
<div class="pagination-section">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="1000"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="chart-section">
|
||||
<ECharts
|
||||
:data="salesData"
|
||||
:config="chartConfig"
|
||||
title="销量对比"
|
||||
height="300px"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import ECharts from '@/components/ECharts/index.vue'
|
||||
import { useSalesChart } from './composables/useSalesChart'
|
||||
|
||||
const value = ref('')
|
||||
const options = [
|
||||
{
|
||||
value: 'Option1',
|
||||
label: 'Option1',
|
||||
},
|
||||
{
|
||||
value: 'Option2',
|
||||
label: 'Option2',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: 'Option3',
|
||||
label: 'Option3',
|
||||
},
|
||||
{
|
||||
value: 'Option4',
|
||||
label: 'Option4',
|
||||
},
|
||||
{
|
||||
value: 'Option5',
|
||||
label: 'Option5',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
// 树形数据
|
||||
const treeData = ref([
|
||||
{
|
||||
id: 1,
|
||||
label: '父级1',
|
||||
children: [
|
||||
{ id: 11, label: '子级1-1' },
|
||||
{ id: 12, label: '子级1-2' },
|
||||
{
|
||||
id: 13,
|
||||
label: '子级1-3',
|
||||
children: [
|
||||
{ id: 131, label: '子子级1-3-1' },
|
||||
{ id: 132, label: '子子级1-3-2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '父级2',
|
||||
children: [
|
||||
{ id: 21, label: '子级2-1' },
|
||||
{ id: 22, label: '子级2-2' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
|
||||
const tableData = [
|
||||
{
|
||||
date: '2016-05-03',
|
||||
name: 'Tom',
|
||||
address: 'No. 189, Grove St, Los Angeles',
|
||||
},
|
||||
{
|
||||
date: '2016-05-02',
|
||||
name: 'Tom',
|
||||
address: 'No. 189, Grove St, Los Angeles',
|
||||
},
|
||||
{
|
||||
date: '2016-05-04',
|
||||
name: 'Tom',
|
||||
address: 'No. 189, Grove St, Los Angeles',
|
||||
},
|
||||
{
|
||||
date: '2016-05-01',
|
||||
name: 'Tom',
|
||||
address: 'No. 189, Grove St, Los Angeles',
|
||||
},
|
||||
{
|
||||
date: '2016-05-03',
|
||||
name: 'Tom',
|
||||
address: 'No. 189, Grove St, Los Angeles',
|
||||
},
|
||||
{
|
||||
date: '2016-05-02',
|
||||
name: 'Tom',
|
||||
address: 'No. 189, Grove St, Los Angeles',
|
||||
},
|
||||
{
|
||||
date: '2016-05-04',
|
||||
name: 'Tom',
|
||||
address: 'No. 189, Grove St, Los Angeles',
|
||||
},
|
||||
{
|
||||
date: '2016-05-01',
|
||||
name: 'Tom',
|
||||
address: 'No. 189, Grove St, Los Angeles',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
// 树形选择的配置属性
|
||||
const treeProps = {
|
||||
value: 'id',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
};
|
||||
|
||||
// 当前选中的值
|
||||
const selectedValue = ref(null);
|
||||
|
||||
// 处理节点选中事件
|
||||
const handleCheck = (currentNode, checkedNodes) => {
|
||||
// 获取所有选中的节点
|
||||
const checked = checkedNodes.checkedNodes;
|
||||
|
||||
// 用于存储最终的叶子节点id
|
||||
const finalIds = new Set(); // 使用Set避免重复
|
||||
|
||||
checked.forEach(node => {
|
||||
// 判断是否是叶子节点(即没有子节点)
|
||||
if (!node.children || node.children.length === 0) {
|
||||
finalIds.add(node.id);
|
||||
} else {
|
||||
// 如果是父级节点,递归获取其下的叶子节点id
|
||||
const leafIds = getLeafIds(node, new Set());
|
||||
leafIds.forEach(id => finalIds.add(id));
|
||||
}
|
||||
});
|
||||
|
||||
console.log('最终的叶子节点id列表:', Array.from(finalIds));
|
||||
};
|
||||
|
||||
// 递归获取叶子节点id
|
||||
const getLeafIds = (node, visited) => {
|
||||
const ids = new Set();
|
||||
if (node.children) {
|
||||
node.children.forEach(child => {
|
||||
if (!child.children || child.children.length === 0) {
|
||||
// 如果是叶子节点,添加其id
|
||||
ids.add(child.id);
|
||||
} else {
|
||||
// 如果不是叶子节点,继续递归
|
||||
if (!visited.has(child.id)) {
|
||||
visited.add(child.id);
|
||||
const childIds = getLeafIds(child, visited);
|
||||
childIds.forEach(id => ids.add(id));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
// 图表相关
|
||||
const { salesData, chartConfig, loading } = useSalesChart()
|
||||
|
||||
// 2. 用户增长趋势数据
|
||||
const userGrowthData = ref([
|
||||
{ month: '2023-04', newUsers: 1500, activeUsers: 12000, retention: 85 },
|
||||
{ month: '2023-05', newUsers: 1800, activeUsers: 13200, retention: 87 },
|
||||
{ month: '2023-06', newUsers: 2200, activeUsers: 14800, retention: 88 },
|
||||
{ month: '2023-07', newUsers: 2100, activeUsers: 15900, retention: 86 },
|
||||
{ month: '2023-08', newUsers: 2600, activeUsers: 17200, retention: 89 },
|
||||
{ month: '2023-09', newUsers: 2800, activeUsers: 18900, retention: 90 },
|
||||
{ month: '2023-10', newUsers: 3100, activeUsers: 21000, retention: 91 },
|
||||
{ month: '2023-11', newUsers: 3400, activeUsers: 23500, retention: 89 },
|
||||
{ month: '2023-12', newUsers: 3800, activeUsers: 26000, retention: 92 },
|
||||
{ month: '2024-01', newUsers: 4200, activeUsers: 29000, retention: 93 },
|
||||
{ month: '2024-02', newUsers: 4500, activeUsers: 32000, retention: 91 },
|
||||
{ month: '2024-03', newUsers: 4800, activeUsers: 35000, retention: 92 }
|
||||
])
|
||||
|
||||
// 用户增长趋势配置
|
||||
const userGrowthConfig = {
|
||||
xField: 'month',
|
||||
series: [
|
||||
{
|
||||
name: '新增用户',
|
||||
field: 'newUsers',
|
||||
type: 'bar',
|
||||
color: '#67C23A',
|
||||
showLabel: true,
|
||||
labelFormatter: '{c}人'
|
||||
},
|
||||
{
|
||||
name: '活跃用户',
|
||||
field: 'activeUsers',
|
||||
type: 'line',
|
||||
color: '#409EFF',
|
||||
showLabel: true,
|
||||
yAxisIndex: 1,
|
||||
labelFormatter: '{c}'
|
||||
},
|
||||
{
|
||||
name: '留存率',
|
||||
field: 'retention',
|
||||
type: 'line',
|
||||
color: '#E6A23C',
|
||||
showLabel: true,
|
||||
yAxisIndex: 2,
|
||||
labelFormatter: '{c}%'
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
name: '新增用户',
|
||||
min: 0
|
||||
},
|
||||
{
|
||||
name: '活跃用户',
|
||||
min: 0,
|
||||
position: 'right'
|
||||
},
|
||||
{
|
||||
name: '留存率',
|
||||
min: 80,
|
||||
max: 100,
|
||||
position: 'right',
|
||||
offset: 80,
|
||||
axisLabel: '{value}%'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 3. 市场份额数据
|
||||
const marketShareData = ref([
|
||||
{ category: '手机', value: 35, lastYear: 32 },
|
||||
{ category: '电脑', value: 25, lastYear: 28 },
|
||||
{ category: '平板', value: 20, lastYear: 18 },
|
||||
{ category: '智能手表', value: 12, lastYear: 10 },
|
||||
{ category: '耳机', value: 8, lastYear: 12 }
|
||||
])
|
||||
|
||||
// 市场份额配置
|
||||
const marketShareConfig = {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
field: 'value',
|
||||
name: '市场份额',
|
||||
radius: ['50%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
showLabel: true,
|
||||
labelFormatter: '{b}: {c}%',
|
||||
itemStyle: {
|
||||
borderRadius: 6
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const handleCellClick = (row, column, cell, event) => {
|
||||
console.log('点击的值:', row[column.property])
|
||||
console.log('点击的列 字段名:', column.property)
|
||||
console.log('点击的列 标题:', column.label)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.test-center {
|
||||
padding: 16px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
|
||||
.search-section {
|
||||
background-color: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.operation-section {
|
||||
background-color: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.left-area {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right-area {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-section {
|
||||
background-color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 12px;
|
||||
|
||||
:deep(.el-table) {
|
||||
// 移除表格的外边框
|
||||
--el-table-border: none;
|
||||
// 减小表格行的上下padding
|
||||
--el-table-row-height: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
background-color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
background-color: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,121 @@
|
|||
import { ref, onMounted } from 'vue'
|
||||
|
||||
export function useSalesChart() {
|
||||
// 销量对比数据
|
||||
const salesData = ref([])
|
||||
const loading = ref(true) // 添加 loading 状态
|
||||
|
||||
// 模拟异步数据加载
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 模拟 API 延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
salesData.value = [
|
||||
{
|
||||
productName: '手机',
|
||||
lastMonthSales: 120,
|
||||
currentMonthSales: 140,
|
||||
growthRate: 16.7
|
||||
},
|
||||
{
|
||||
productName: '耳机',
|
||||
lastMonthSales: 120,
|
||||
currentMonthSales: 140,
|
||||
growthRate: 16.7
|
||||
},
|
||||
{
|
||||
productName: '手机',
|
||||
lastMonthSales: 120,
|
||||
currentMonthSales: 140,
|
||||
growthRate: 16.7
|
||||
},
|
||||
{
|
||||
productName: '手机',
|
||||
lastMonthSales: 120,
|
||||
currentMonthSales: 140,
|
||||
growthRate: 16.7
|
||||
},
|
||||
// ... 其他数据
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const chartConfig = {
|
||||
xField: 'productName',
|
||||
series: [
|
||||
{
|
||||
name: '上月销量',
|
||||
field: 'lastMonthSales',
|
||||
type: 'bar',
|
||||
color: '#409EFF',
|
||||
showLabel: true,
|
||||
legendShow: false,
|
||||
labelFormatter: '{c}台',
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: '本月销量',
|
||||
field: 'currentMonthSales',
|
||||
type: 'bar',
|
||||
color: '#67C23A',
|
||||
showLabel: true,
|
||||
labelFormatter: '{c}台',
|
||||
show: false,
|
||||
tooltip: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '环比增长',
|
||||
field: 'growthRate',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
color: '#E6A23C',
|
||||
showLabel: true,
|
||||
labelFormatter: '{c}%',
|
||||
tooltip: {
|
||||
show: true
|
||||
}
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
name: '销量',
|
||||
min: 0
|
||||
},
|
||||
{
|
||||
name: '增长率',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: '{value}%'
|
||||
}
|
||||
],
|
||||
dataZoom: [ // 自定义 dataZoom 配置
|
||||
{
|
||||
type: 'slider',
|
||||
xAxisIndex: 0,
|
||||
start: 10,
|
||||
end: 60,
|
||||
height: 20,
|
||||
bottom: 20
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
salesData,
|
||||
chartConfig,
|
||||
loading
|
||||
}
|
||||
}
|
|
@ -7,10 +7,9 @@
|
|||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.announcement {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
@ -204,7 +204,7 @@ const courses = ref([
|
|||
id: 1,
|
||||
title: 'C++面向对象编程精讲',
|
||||
description: '深入理解C++面向对象编程思想,掌握核心概念和实践技巧',
|
||||
coverImg: '/src/assets/images/course-covers/cpp.jpg',
|
||||
coverImg: 'http://localhost:9000/file/c6d07740-7306-4fc1-b1f8-670684a73ed9/img/31c258e7-3fd4-402c-8f2f-c9455c6cfdaf.png',
|
||||
teacher: '张教授',
|
||||
studentCount: 1234,
|
||||
rating: 4.8,
|
||||
|
|
|
@ -252,7 +252,7 @@ const courseData = ref({
|
|||
docCount: 5,
|
||||
totalDuration: '24小时',
|
||||
enrolled: false,
|
||||
coverImg: 'http://example.com/course-cover.jpg',
|
||||
coverImg: 'http://localhost:9000/file/c6d07740-7306-4fc1-b1f8-670684a73ed9/img/31c258e7-3fd4-402c-8f2f-c9455c6cfdaf.png',
|
||||
chapters: [
|
||||
{
|
||||
id: 1,
|
||||
|
|
|
@ -1,16 +1,410 @@
|
|||
<template>
|
||||
<div class="mall">
|
||||
<h1 class="page-title">商城</h1>
|
||||
<p>商城功能正在开发中,敬请期待...</p>
|
||||
<div class="challenge-container">
|
||||
<div class="challenge-header">
|
||||
<div class="title-section">
|
||||
<h2>{{ challenge.title }}</h2>
|
||||
<div class="difficulty easy">简单</div>
|
||||
</div>
|
||||
<div class="challenge-info">
|
||||
<span class="info-tag">
|
||||
<i class="fas fa-memory"></i>
|
||||
内存限制:{{ challenge.memoryLimit }} MB
|
||||
</span>
|
||||
<span class="info-tag">
|
||||
<i class="fas fa-code"></i>
|
||||
{{ challenge.language }}
|
||||
</span>
|
||||
<span class="info-tag">
|
||||
<i class="fas fa-clock"></i>
|
||||
时间限制:1000ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="challenge-content">
|
||||
<div class="test-cases-section">
|
||||
<div class="section-header">
|
||||
<h3><i class="fas fa-vial"></i> 测试用例</h3>
|
||||
<span class="case-count">{{ challenge.testCases.length }} 个用例</span>
|
||||
</div>
|
||||
<div class="test-cases">
|
||||
<div v-for="(test, index) in challenge.testCases"
|
||||
:key="index"
|
||||
class="test-case-card">
|
||||
<div class="test-case-header">
|
||||
<span class="case-number">#{{ index + 1 }}</span>
|
||||
<span class="case-status success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="test-case-content">
|
||||
<div class="test-input">
|
||||
<div class="label">输入:</div>
|
||||
<code>{{ test.input }}</code>
|
||||
</div>
|
||||
<div class="test-output">
|
||||
<div class="label">期望输出:</div>
|
||||
<code>{{ test.expectedOutput }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs">
|
||||
<div class="tab active">
|
||||
<i class="fas fa-code"></i>
|
||||
代码编辑器
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button class="action-btn">
|
||||
<i class="fas fa-redo"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="submit-btn" @click="submitCode">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
提交代码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodeEditor
|
||||
v-model="userCode"
|
||||
:language="challenge.language"
|
||||
class="code-editor-wrapper"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="result-section">
|
||||
<div class="section-header">
|
||||
<h3><i class="fas fa-poll"></i> 执行结果</h3>
|
||||
</div>
|
||||
<pre class="result-content">{{ result }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script>
|
||||
import { ref } from "vue";
|
||||
import CodeEditor from "@/components/CodeEditor/CodeEditor.vue";
|
||||
|
||||
export default {
|
||||
components: { CodeEditor },
|
||||
setup() {
|
||||
const challenge = {
|
||||
title: "两数之和",
|
||||
memoryLimit: 256,
|
||||
language: "javascript",
|
||||
testCases: [
|
||||
{ input: "[2,7,11,15], 9", expectedOutput: "[0,1]" },
|
||||
{ input: "[3,2,4], 6", expectedOutput: "[1,2]" },
|
||||
{ input: "[3,3], 6", expectedOutput: "[0,1]" },
|
||||
],
|
||||
};
|
||||
|
||||
const userCode = ref('// 在这里编写你的代码\nfunction twoSum(nums, target) {\n return [];\n}');
|
||||
|
||||
const result = ref(null);
|
||||
|
||||
const submitCode = () => {
|
||||
result.value = {
|
||||
code: userCode.value,
|
||||
challenge,
|
||||
};
|
||||
console.log("提交代码:", result.value);
|
||||
};
|
||||
|
||||
return { challenge, userCode, submitCode, result };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mall {
|
||||
<style scoped>
|
||||
.challenge-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
.challenge-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.challenge-header h2 {
|
||||
margin: 0;
|
||||
color: #1a202c;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.difficulty.easy {
|
||||
background: #e6f6e6;
|
||||
color: #2f9e44;
|
||||
}
|
||||
|
||||
.challenge-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.info-tag:hover {
|
||||
background: #f1f5f9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.info-tag i {
|
||||
margin-right: 8px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.challenge-content {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #1a202c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-header h3 i {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.case-count {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.test-cases {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.test-case-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.test-case-card:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.test-case-header {
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.case-number {
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.case-status {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.case-status.success i {
|
||||
color: #2f9e44;
|
||||
}
|
||||
|
||||
.test-case-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.test-input, .test-output {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
background: #f8fafc;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #f1f5f9;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.code-editor-wrapper {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
flex-grow: 1;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
grid-column: 1 / -1;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.challenge-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.test-cases-section, .editor-section {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="goods-detail">
|
||||
<NavBar :active="'/shop'"></NavBar>
|
||||
|
||||
<el-card class="detail-card">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<img :src="goods.image" class="goods-image">
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<h1>{{ goods.name }}</h1>
|
||||
<div class="goods-price">{{ goods.points }} 积分</div>
|
||||
<div class="goods-desc">{{ goods.description }}</div>
|
||||
<div class="goods-action">
|
||||
<el-button type="primary" size="large" @click="handleExchange">
|
||||
立即兑换
|
||||
</el-button>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,203 @@
|
|||
<template>
|
||||
<div class="shop-container">
|
||||
<NavBar :active="'/shop'"></NavBar>
|
||||
|
||||
<!-- 积分余额显示 -->
|
||||
<div class="points-balance">
|
||||
<el-card>
|
||||
<div class="balance-content">
|
||||
<i class="el-icon-coin"></i>
|
||||
<span class="points">当前积分: {{ userPoints }}</span>
|
||||
<el-button type="primary" size="small" @click="showPointsHistory">
|
||||
积分明细
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<div class="goods-list">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" v-for="item in goodsList" :key="item.id">
|
||||
<el-card :body-style="{ padding: '0px' }" class="goods-card">
|
||||
<img :src="item.image" class="goods-image">
|
||||
<div class="goods-info">
|
||||
<h3>{{ item.name }}</h3>
|
||||
<div class="price-info">
|
||||
<span class="points-price">{{ item.points }} 积分</span>
|
||||
<el-button type="primary" size="small" @click="handleExchange(item)">
|
||||
立即兑换
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 积分明细对话框 -->
|
||||
<el-dialog
|
||||
title="积分明细"
|
||||
v-model="pointsHistoryVisible"
|
||||
width="60%">
|
||||
<el-table :data="pointsHistory">
|
||||
<el-table-column prop="time" label="时间" width="180"/>
|
||||
<el-table-column prop="type" label="类型" width="120"/>
|
||||
<el-table-column prop="points" label="积分变动"/>
|
||||
<el-table-column prop="description" label="说明"/>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NavBar from '@/components/oj/common/NavBar.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export default {
|
||||
name: 'Shop',
|
||||
components: {
|
||||
NavBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userPoints: 1000, // 用户积分余额
|
||||
pointsHistoryVisible: false,
|
||||
goodsList: [
|
||||
{
|
||||
id: 1,
|
||||
name: '力扣会员1个月',
|
||||
points: 500,
|
||||
image: 'https://example.com/leetcode.png',
|
||||
description: '力扣会员1个月使用权限'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '编程书籍优惠券',
|
||||
points: 300,
|
||||
image: 'https://example.com/book.png',
|
||||
description: '任意编程书籍立减30元'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'IDE专业版',
|
||||
points: 800,
|
||||
image: 'https://example.com/ide.png',
|
||||
description: 'JetBrains IDE 1个月使用授权'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '在线课程优惠券',
|
||||
points: 400,
|
||||
image: 'https://example.com/course.png',
|
||||
description: '指定在线课程优惠50元'
|
||||
}
|
||||
],
|
||||
pointsHistory: [
|
||||
{
|
||||
time: '2024-03-20 10:00:00',
|
||||
type: '题目通过',
|
||||
points: '+10',
|
||||
description: '完成题目【两数之和】'
|
||||
},
|
||||
{
|
||||
time: '2024-03-19 15:30:00',
|
||||
type: '商品兑换',
|
||||
points: '-300',
|
||||
description: '兑换【编程书籍优惠券】'
|
||||
},
|
||||
{
|
||||
time: '2024-03-18 09:15:00',
|
||||
type: '每日登录',
|
||||
points: '+5',
|
||||
description: '每日登录奖励'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showPointsHistory() {
|
||||
this.pointsHistoryVisible = true
|
||||
},
|
||||
handleExchange(item) {
|
||||
if (this.userPoints < item.points) {
|
||||
ElMessage.warning('积分不足!')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('兑换成功!')
|
||||
this.userPoints -= item.points
|
||||
// TODO: 调用后端API完成兑换
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shop-container {
|
||||
padding: 20px;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.points-balance {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.balance-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.points {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.goods-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.goods-card {
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.goods-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
.goods-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.goods-info {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.goods-info h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.points-price {
|
||||
color: #F56C6C;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue