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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.4.0",
|
"@codemirror/basic-setup": "^0.20.0",
|
||||||
"vue-router": "^4.2.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"element-plus": "^2.5.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",
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
"echarts": "^5.5.0",
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
"vue-echarts": "^6.6.0",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"three": "^0.160.0",
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
"file-saver": "^2.0.5",
|
"@iconify/vue": "^4.3.0",
|
||||||
"xlsx": "^0.18.5",
|
"@monaco-editor/loader": "^1.5.0",
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"@vueuse/core": "^13.0.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"pinia": "^2.1.0",
|
|
||||||
"dayjs": "^1.11.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",
|
"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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
"vite": "^5.0.0",
|
|
||||||
"sass": "^1.70.0",
|
"sass": "^1.70.0",
|
||||||
"unplugin-auto-import": "^0.17.0",
|
"unplugin-auto-import": "^0.17.0",
|
||||||
"unplugin-vue-components": "^0.26.0"
|
"unplugin-vue-components": "^0.26.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<AppHeader />
|
<AppHeader v-if="!$route.meta.hideNav"/>
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
<AppFooter />
|
<AppFooter v-if="!$route.meta.hideNav"/>
|
||||||
|
<AIChatBot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppHeader from './components/AppHeader.vue'
|
import AppHeader from './components/AppHeader.vue'
|
||||||
import AppFooter from './components/AppFooter.vue'
|
import AppFooter from './components/AppFooter.vue'
|
||||||
|
import AIChatBot from '@/components/AIChatBot/AIChatBot.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
@ -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) {
|
export function getCoursesMethod(params) {
|
||||||
return get('/bs/courses', params)
|
console.log(params)
|
||||||
|
return get('/bs/courses/homePage', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { post } from '@/utils/request'
|
||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
export function fetchDetail(id) {
|
export function fetchDetail(id) {
|
||||||
return post('/exam/api/exam/exam/detail', { id: id })
|
return post('/api/common/exam/api/exam/exam/detail', { id: id })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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>
|
|
@ -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 'element-plus/dist/index.css'
|
||||||
import './styles/element.scss'
|
import './styles/element.scss'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
// 暂时注释掉这两行,直到我们创建了相应的文件
|
import 'vtron/distlib/style.css';
|
||||||
// import 'animate.css'
|
|
||||||
// import './assets/styles/main.scss'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|
|
@ -67,7 +67,16 @@ const routes = [
|
||||||
path: '/exam/start/:id',
|
path: '/exam/start/:id',
|
||||||
component: () => import('../views/paper/exam/exam.vue'),
|
component: () => import('../views/paper/exam/exam.vue'),
|
||||||
name: 'StartExam',
|
name: 'StartExam',
|
||||||
meta: { title: '开始考试' },
|
|
||||||
|
meta: {
|
||||||
|
|
||||||
|
title: '开始考试',
|
||||||
|
|
||||||
|
isExam: true,
|
||||||
|
|
||||||
|
hideNav: true
|
||||||
|
|
||||||
|
},
|
||||||
hidden: true
|
hidden: true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -87,6 +96,11 @@ const routes = [
|
||||||
name: 'ActivityCenter',
|
name: 'ActivityCenter',
|
||||||
component: () => import('../views/activity/ActivityCenter.vue')
|
component: () => import('../views/activity/ActivityCenter.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/test',
|
||||||
|
name: 'TestCenter',
|
||||||
|
component: () => import('../views/activity/TestCenter.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/mall',
|
path: '/mall',
|
||||||
name: 'Mall',
|
name: 'Mall',
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
|
import qs from 'qs';
|
||||||
|
|
||||||
// 创建 axios 实例
|
// 创建 axios 实例
|
||||||
const service = axios.create({
|
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
|
// 根据请求方法和数据类型动态设置 Content-Type
|
||||||
|
@ -50,6 +51,7 @@ service.interceptors.request.use(
|
||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
service.interceptors.response.use(
|
service.interceptors.response.use(
|
||||||
response => {
|
response => {
|
||||||
|
|
||||||
const res = response.data
|
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) {
|
export function upload(url, file, onUploadProgress) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
|
|
|
@ -170,11 +170,18 @@ const features = [
|
||||||
|
|
||||||
const popularCourses = ref([])
|
const popularCourses = ref([])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const getCourses = async () => {
|
const getCourses = async () => {
|
||||||
var axiosResponse = await getCoursesMethod();
|
|
||||||
|
const params = {
|
||||||
|
page: 1, // 当前页码
|
||||||
|
size: 4, // 每页数量
|
||||||
|
};
|
||||||
|
|
||||||
|
var axiosResponse = await getCoursesMethod(params);
|
||||||
console.log(axiosResponse)
|
console.log(axiosResponse)
|
||||||
popularCourses.value = axiosResponse.data
|
popularCourses.value = axiosResponse.data
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewCourse = (courseId) => {
|
const viewCourse = (courseId) => {
|
||||||
|
|
|
@ -1,11 +1,174 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="activity-center">
|
<el-tree-select
|
||||||
<h1 class="page-title">活动大厅</h1>
|
v-model="selectedValue"
|
||||||
<p>活动大厅功能正在开发中,敬请期待...</p>
|
:data="treeData"
|
||||||
</div>
|
:props="treeProps"
|
||||||
|
show-checkbox
|
||||||
|
@check="handleCheck"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
<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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -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,7 +7,6 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.announcement {
|
.announcement {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
|
|
@ -204,7 +204,7 @@ const courses = ref([
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'C++面向对象编程精讲',
|
title: 'C++面向对象编程精讲',
|
||||||
description: '深入理解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: '张教授',
|
teacher: '张教授',
|
||||||
studentCount: 1234,
|
studentCount: 1234,
|
||||||
rating: 4.8,
|
rating: 4.8,
|
||||||
|
|
|
@ -252,7 +252,7 @@ const courseData = ref({
|
||||||
docCount: 5,
|
docCount: 5,
|
||||||
totalDuration: '24小时',
|
totalDuration: '24小时',
|
||||||
enrolled: false,
|
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: [
|
chapters: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|
|
@ -1,16 +1,410 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="mall">
|
<div class="challenge-container">
|
||||||
<h1 class="page-title">商城</h1>
|
<div class="challenge-header">
|
||||||
<p>商城功能正在开发中,敬请期待...</p>
|
<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>
|
</div>
|
||||||
</template>
|
</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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style scoped>
|
||||||
.mall {
|
.challenge-container {
|
||||||
max-width: 1200px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
</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
|
@ -139,7 +139,7 @@ export default {
|
||||||
// 可批量操作
|
// 可批量操作
|
||||||
multi: false,
|
multi: false,
|
||||||
// 列表请求URL
|
// 列表请求URL
|
||||||
listUrl: '/exam/api/exam/exam/online-paging'
|
listUrl: '/api/common/exam/api/exam/exam/online-paging'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue