feat:新增loading,
This commit is contained in:
parent
ce6a0d41eb
commit
978713b316
198
src/components/common/Loading.vue
Normal file
198
src/components/common/Loading.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="loading-container" :class="containerClass">
|
||||
<!-- 全屏遮罩模式 -->
|
||||
<div v-if="overlay" class="loading-overlay" :class="overlayClass">
|
||||
<div class="loading-content">
|
||||
<n-spin :size="spinSize" :stroke="strokeColor">
|
||||
<template #description>
|
||||
<div class="loading-text" :style="textStyle">
|
||||
{{ text }}
|
||||
</div>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内联模式 -->
|
||||
<div v-else class="loading-inline" :class="inlineClass">
|
||||
<n-spin :size="spinSize" :stroke="strokeColor">
|
||||
<template #description v-if="text">
|
||||
<div class="loading-text" :style="textStyle">
|
||||
{{ text }}
|
||||
</div>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NSpin } from 'naive-ui'
|
||||
|
||||
interface Props {
|
||||
// 是否显示加载状态
|
||||
loading?: boolean
|
||||
// 加载文本
|
||||
text?: string
|
||||
// 是否显示遮罩层
|
||||
overlay?: boolean
|
||||
// 尺寸:small | medium | large
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
// 自定义颜色
|
||||
color?: string
|
||||
// 文本颜色
|
||||
textColor?: string
|
||||
// 背景颜色(遮罩模式)
|
||||
backgroundColor?: string
|
||||
// 透明度(遮罩模式)
|
||||
opacity?: number
|
||||
// 自定义类名
|
||||
customClass?: string
|
||||
// z-index
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: true,
|
||||
text: '加载中...',
|
||||
overlay: false,
|
||||
size: 'medium',
|
||||
color: '#1890ff',
|
||||
textColor: '#666666',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
opacity: 0.8,
|
||||
customClass: '',
|
||||
zIndex: 1000
|
||||
})
|
||||
|
||||
// 计算 Spin 组件的尺寸
|
||||
const spinSize = computed(() => {
|
||||
const sizeMap = {
|
||||
small: 'small',
|
||||
medium: 'medium',
|
||||
large: 'large'
|
||||
}
|
||||
return sizeMap[props.size] as 'small' | 'medium' | 'large'
|
||||
})
|
||||
|
||||
// 计算颜色
|
||||
const strokeColor = computed(() => props.color)
|
||||
|
||||
// 计算文本样式
|
||||
const textStyle = computed(() => ({
|
||||
color: props.textColor,
|
||||
marginTop: '12px',
|
||||
fontSize: props.size === 'small' ? '12px' : props.size === 'large' ? '16px' : '14px'
|
||||
}))
|
||||
|
||||
// 计算容器类名
|
||||
const containerClass = computed(() => [
|
||||
props.customClass,
|
||||
{
|
||||
'loading-container--overlay': props.overlay,
|
||||
'loading-container--inline': !props.overlay
|
||||
}
|
||||
])
|
||||
|
||||
// 计算遮罩层类名和样式
|
||||
const overlayClass = computed(() => ({
|
||||
'loading-overlay--visible': props.loading
|
||||
}))
|
||||
|
||||
// 计算内联模式类名
|
||||
const inlineClass = computed(() => ({
|
||||
'loading-inline--visible': props.loading
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 遮罩模式样式 */
|
||||
.loading-container--overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: v-bind(zIndex);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: v-bind(backgroundColor);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-overlay--visible {
|
||||
opacity: v-bind(opacity);
|
||||
visibility: visible;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 内联模式样式 */
|
||||
.loading-container--inline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.loading-inline {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading-inline--visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.loading-content {
|
||||
padding: 20px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
337
src/components/common/LoadingExample.vue
Normal file
337
src/components/common/LoadingExample.vue
Normal file
@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="loading-example">
|
||||
<h2>Loading 组件使用示例</h2>
|
||||
|
||||
<!-- 基础用法 -->
|
||||
<div class="example-section">
|
||||
<h3>1. 基础用法</h3>
|
||||
<div class="example-content">
|
||||
<n-button @click="showBasicLoading" type="primary">
|
||||
显示基础 Loading
|
||||
</n-button>
|
||||
<n-button @click="hideBasicLoading" type="default">
|
||||
隐藏 Loading
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 内联 Loading 示例 -->
|
||||
<div class="inline-loading-demo">
|
||||
<Loading
|
||||
:loading="basicLoading"
|
||||
text="正在加载数据..."
|
||||
:overlay="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 不同尺寸 -->
|
||||
<div class="example-section">
|
||||
<h3>2. 不同尺寸</h3>
|
||||
<div class="example-content">
|
||||
<n-button @click="showSmallLoading" size="small">
|
||||
小尺寸 Loading
|
||||
</n-button>
|
||||
<n-button @click="showMediumLoading">
|
||||
中等尺寸 Loading
|
||||
</n-button>
|
||||
<n-button @click="showLargeLoading" size="large">
|
||||
大尺寸 Loading
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义颜色 -->
|
||||
<div class="example-section">
|
||||
<h3>3. 自定义颜色</h3>
|
||||
<div class="example-content">
|
||||
<n-button @click="showRedLoading" type="error">
|
||||
红色 Loading
|
||||
</n-button>
|
||||
<n-button @click="showGreenLoading" type="success">
|
||||
绿色 Loading
|
||||
</n-button>
|
||||
<n-button @click="showPurpleLoading" type="info">
|
||||
紫色 Loading
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 异步操作包装 -->
|
||||
<div class="example-section">
|
||||
<h3>4. 异步操作包装</h3>
|
||||
<div class="example-content">
|
||||
<n-button @click="simulateApiCall" type="primary">
|
||||
模拟 API 调用
|
||||
</n-button>
|
||||
<n-button @click="simulateApiError" type="error">
|
||||
模拟 API 错误
|
||||
</n-button>
|
||||
<n-button @click="simulateProgressLoading" type="info">
|
||||
模拟进度加载
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 组件内 Loading -->
|
||||
<div class="example-section">
|
||||
<h3>5. 组件内 Loading Hook</h3>
|
||||
<div class="example-content">
|
||||
<n-button @click="toggleComponentLoading" :type="componentLoading ? 'default' : 'primary'">
|
||||
{{ componentLoading ? '停止' : '开始' }} 组件加载
|
||||
</n-button>
|
||||
<div class="component-loading-demo">
|
||||
<Loading
|
||||
v-if="componentLoading"
|
||||
:loading="componentLoading"
|
||||
:text="componentLoadingText"
|
||||
size="small"
|
||||
color="#52c41a"
|
||||
/>
|
||||
<div v-else class="demo-content">
|
||||
<p>这里是组件内容</p>
|
||||
<p>当 Loading 显示时,这些内容会被遮盖</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果显示 -->
|
||||
<div v-if="result" class="result-section">
|
||||
<h3>操作结果</h3>
|
||||
<n-alert :type="result.type" :title="result.title">
|
||||
{{ result.message }}
|
||||
</n-alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NButton, NAlert, useMessage } from 'naive-ui'
|
||||
import Loading, { useLoading } from '@/composables/useLoading'
|
||||
|
||||
// 使用 Loading Hook
|
||||
const { loading: componentLoading, loadingText: componentLoadingText, showLoading: showComponentLoading, hideLoading: hideComponentLoading, updateLoadingText } = useLoading()
|
||||
|
||||
const message = useMessage()
|
||||
const basicLoading = ref(false)
|
||||
const result = ref<{ type: 'success' | 'error' | 'info' | 'warning', title: string, message: string } | null>(null)
|
||||
|
||||
// 基础用法
|
||||
const showBasicLoading = () => {
|
||||
basicLoading.value = true
|
||||
}
|
||||
|
||||
const hideBasicLoading = () => {
|
||||
basicLoading.value = false
|
||||
}
|
||||
|
||||
// 不同尺寸
|
||||
const showSmallLoading = () => {
|
||||
Loading.show({
|
||||
text: '小尺寸加载中...',
|
||||
size: 'small'
|
||||
})
|
||||
setTimeout(() => Loading.hide(), 2000)
|
||||
}
|
||||
|
||||
const showMediumLoading = () => {
|
||||
Loading.show({
|
||||
text: '中等尺寸加载中...',
|
||||
size: 'medium'
|
||||
})
|
||||
setTimeout(() => Loading.hide(), 2000)
|
||||
}
|
||||
|
||||
const showLargeLoading = () => {
|
||||
Loading.show({
|
||||
text: '大尺寸加载中...',
|
||||
size: 'large'
|
||||
})
|
||||
setTimeout(() => Loading.hide(), 2000)
|
||||
}
|
||||
|
||||
// 自定义颜色
|
||||
const showRedLoading = () => {
|
||||
Loading.show({
|
||||
text: '红色加载中...',
|
||||
color: '#ff4d4f',
|
||||
textColor: '#ff4d4f'
|
||||
})
|
||||
setTimeout(() => Loading.hide(), 2000)
|
||||
}
|
||||
|
||||
const showGreenLoading = () => {
|
||||
Loading.show({
|
||||
text: '绿色加载中...',
|
||||
color: '#52c41a',
|
||||
textColor: '#52c41a'
|
||||
})
|
||||
setTimeout(() => Loading.hide(), 2000)
|
||||
}
|
||||
|
||||
const showPurpleLoading = () => {
|
||||
Loading.show({
|
||||
text: '紫色加载中...',
|
||||
color: '#722ed1',
|
||||
textColor: '#722ed1'
|
||||
})
|
||||
setTimeout(() => Loading.hide(), 2000)
|
||||
}
|
||||
|
||||
// 模拟 API 调用
|
||||
const simulateApiCall = async () => {
|
||||
try {
|
||||
const data = await Loading.wrap(
|
||||
() => new Promise(resolve => setTimeout(() => resolve('API 数据'), 3000)),
|
||||
{
|
||||
text: '正在获取数据...',
|
||||
color: '#1890ff'
|
||||
}
|
||||
)
|
||||
|
||||
result.value = {
|
||||
type: 'success',
|
||||
title: '成功',
|
||||
message: `获取到数据: ${data}`
|
||||
}
|
||||
message.success('API 调用成功!')
|
||||
} catch (error) {
|
||||
console.error('API 调用失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟 API 错误
|
||||
const simulateApiError = async () => {
|
||||
try {
|
||||
await Loading.wrap(
|
||||
() => new Promise((_, reject) => setTimeout(() => reject(new Error('网络错误')), 2000)),
|
||||
{
|
||||
text: '正在处理请求...',
|
||||
color: '#ff4d4f',
|
||||
onError: (error) => {
|
||||
result.value = {
|
||||
type: 'error',
|
||||
title: '错误',
|
||||
message: error.message
|
||||
}
|
||||
message.error('API 调用失败!')
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
// 错误已在 onError 中处理
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟进度加载
|
||||
const simulateProgressLoading = () => {
|
||||
Loading.show({
|
||||
text: '准备中... 0%',
|
||||
color: '#1890ff'
|
||||
})
|
||||
|
||||
let progress = 0
|
||||
const interval = setInterval(() => {
|
||||
progress += 10
|
||||
Loading.updateText(`加载中... ${progress}%`)
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval)
|
||||
setTimeout(() => {
|
||||
Loading.hide()
|
||||
result.value = {
|
||||
type: 'success',
|
||||
title: '完成',
|
||||
message: '进度加载完成!'
|
||||
}
|
||||
message.success('加载完成!')
|
||||
}, 500)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 组件内 Loading
|
||||
const toggleComponentLoading = () => {
|
||||
if (componentLoading.value) {
|
||||
hideComponentLoading()
|
||||
} else {
|
||||
showComponentLoading('组件加载中...')
|
||||
|
||||
// 模拟加载过程
|
||||
setTimeout(() => updateLoadingText('即将完成...'), 1500)
|
||||
setTimeout(() => hideComponentLoading(), 3000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-example {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.example-section {
|
||||
margin-bottom: 32px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.example-section h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.example-content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inline-loading-demo {
|
||||
height: 100px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.component-loading-demo {
|
||||
height: 120px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.result-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
243
src/composables/useLoading.ts
Normal file
243
src/composables/useLoading.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { ref } from 'vue'
|
||||
import type {
|
||||
LoadingOptions,
|
||||
LoadingWrapOptions,
|
||||
UseLoadingReturn
|
||||
} from '@/types/loading'
|
||||
|
||||
// 全局 Loading 状态(保留以备后用)
|
||||
// const globalLoading = ref(false)
|
||||
// const globalLoadingText = ref('加载中...')
|
||||
// const globalLoadingOptions = ref<LoadingOptions>({})
|
||||
|
||||
// 创建全局遮罩元素
|
||||
let globalLoadingElement: HTMLElement | null = null
|
||||
|
||||
/**
|
||||
* 创建全局 Loading 遮罩
|
||||
*/
|
||||
function createGlobalLoadingElement() {
|
||||
if (globalLoadingElement) return
|
||||
|
||||
globalLoadingElement = document.createElement('div')
|
||||
globalLoadingElement.id = 'global-loading-overlay'
|
||||
globalLoadingElement.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
`
|
||||
|
||||
const content = document.createElement('div')
|
||||
content.style.cssText = `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 120px;
|
||||
`
|
||||
|
||||
const spinner = document.createElement('div')
|
||||
spinner.style.cssText = `
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #1890ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
|
||||
const text = document.createElement('div')
|
||||
text.id = 'global-loading-text'
|
||||
text.style.cssText = `
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
`
|
||||
text.textContent = '加载中...'
|
||||
|
||||
// 添加旋转动画
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
|
||||
content.appendChild(spinner)
|
||||
content.appendChild(text)
|
||||
globalLoadingElement.appendChild(content)
|
||||
document.body.appendChild(globalLoadingElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示全局 Loading
|
||||
*/
|
||||
function showGlobalLoading(options: LoadingOptions = {}) {
|
||||
createGlobalLoadingElement()
|
||||
|
||||
if (globalLoadingElement) {
|
||||
const textElement = globalLoadingElement.querySelector('#global-loading-text') as HTMLElement
|
||||
if (textElement) {
|
||||
textElement.textContent = options.text || '加载中...'
|
||||
}
|
||||
|
||||
// 更新样式
|
||||
if (options.color) {
|
||||
const spinner = globalLoadingElement.querySelector('div > div') as HTMLElement
|
||||
if (spinner) {
|
||||
spinner.style.borderTopColor = options.color
|
||||
}
|
||||
}
|
||||
|
||||
if (options.backgroundColor) {
|
||||
globalLoadingElement.style.background = options.backgroundColor
|
||||
}
|
||||
|
||||
if (options.zIndex) {
|
||||
globalLoadingElement.style.zIndex = options.zIndex.toString()
|
||||
}
|
||||
|
||||
// 显示
|
||||
globalLoadingElement.style.opacity = '1'
|
||||
globalLoadingElement.style.visibility = 'visible'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏全局 Loading
|
||||
*/
|
||||
function hideGlobalLoading() {
|
||||
if (globalLoadingElement) {
|
||||
globalLoadingElement.style.opacity = '0'
|
||||
globalLoadingElement.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新全局 Loading 文本
|
||||
*/
|
||||
function updateGlobalLoadingText(text: string) {
|
||||
if (globalLoadingElement) {
|
||||
const textElement = globalLoadingElement.querySelector('#global-loading-text') as HTMLElement
|
||||
if (textElement) {
|
||||
textElement.textContent = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁全局 Loading
|
||||
*/
|
||||
function destroyGlobalLoading() {
|
||||
if (globalLoadingElement) {
|
||||
document.body.removeChild(globalLoadingElement)
|
||||
globalLoadingElement = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件内使用的 Loading Hook
|
||||
*/
|
||||
export function useLoading(initialLoading = false): UseLoadingReturn {
|
||||
const loading = ref(initialLoading)
|
||||
const loadingText = ref('加载中...')
|
||||
|
||||
const showLoading = (text = '加载中...') => {
|
||||
loadingText.value = text
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
const hideLoading = () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const updateLoadingText = (text: string) => {
|
||||
loadingText.value = text
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
loadingText,
|
||||
showLoading,
|
||||
hideLoading,
|
||||
updateLoadingText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 Loading 方法
|
||||
*/
|
||||
export const Loading = {
|
||||
/**
|
||||
* 显示全局 Loading
|
||||
*/
|
||||
show: (options?: LoadingOptions) => {
|
||||
showGlobalLoading(options)
|
||||
},
|
||||
|
||||
/**
|
||||
* 隐藏全局 Loading
|
||||
*/
|
||||
hide: () => {
|
||||
hideGlobalLoading()
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新 Loading 文本
|
||||
*/
|
||||
updateText: (text: string) => {
|
||||
updateGlobalLoadingText(text)
|
||||
},
|
||||
|
||||
/**
|
||||
* 销毁全局 Loading
|
||||
*/
|
||||
destroy: () => {
|
||||
destroyGlobalLoading()
|
||||
},
|
||||
|
||||
/**
|
||||
* 异步操作包装器
|
||||
*/
|
||||
async wrap<T>(
|
||||
asyncFn: () => Promise<T>,
|
||||
options?: LoadingWrapOptions
|
||||
): Promise<T> {
|
||||
try {
|
||||
Loading.show(options)
|
||||
const result = await asyncFn()
|
||||
return result
|
||||
} catch (error) {
|
||||
if (options?.onError) {
|
||||
options.onError(error)
|
||||
} else {
|
||||
console.error('Loading.wrap error:', error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
Loading.hide()
|
||||
if (options?.finallyCallback) {
|
||||
options.finallyCallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Loading
|
77
src/types/loading.ts
Normal file
77
src/types/loading.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Loading 组件相关类型定义
|
||||
*/
|
||||
|
||||
export interface LoadingOptions {
|
||||
/** 加载文本 */
|
||||
text?: string
|
||||
/** 尺寸:small | medium | large */
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
/** 自定义颜色 */
|
||||
color?: string
|
||||
/** 文本颜色 */
|
||||
textColor?: string
|
||||
/** 背景颜色(遮罩模式) */
|
||||
backgroundColor?: string
|
||||
/** 透明度(遮罩模式) */
|
||||
opacity?: number
|
||||
/** z-index */
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
export interface LoadingWrapOptions extends LoadingOptions {
|
||||
/** 错误处理回调 */
|
||||
onError?: (error: any) => void
|
||||
/** 最终回调(无论成功失败都会执行) */
|
||||
finallyCallback?: () => void
|
||||
}
|
||||
|
||||
export interface LoadingInstance {
|
||||
/** 显示 Loading */
|
||||
show: (options?: LoadingOptions) => void
|
||||
/** 隐藏 Loading */
|
||||
hide: () => void
|
||||
/** 更新文本 */
|
||||
updateText: (text: string) => void
|
||||
/** 销毁实例 */
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
export interface UseLoadingReturn {
|
||||
/** 加载状态 */
|
||||
loading: Ref<boolean>
|
||||
/** 加载文本 */
|
||||
loadingText: Ref<string>
|
||||
/** 显示加载 */
|
||||
showLoading: (text?: string) => void
|
||||
/** 隐藏加载 */
|
||||
hideLoading: () => void
|
||||
/** 更新加载文本 */
|
||||
updateLoadingText: (text: string) => void
|
||||
}
|
||||
|
||||
export interface LoadingProps {
|
||||
/** 是否显示加载状态 */
|
||||
loading?: boolean
|
||||
/** 加载文本 */
|
||||
text?: string
|
||||
/** 是否显示遮罩层 */
|
||||
overlay?: boolean
|
||||
/** 尺寸:small | medium | large */
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
/** 自定义颜色 */
|
||||
color?: string
|
||||
/** 文本颜色 */
|
||||
textColor?: string
|
||||
/** 背景颜色(遮罩模式) */
|
||||
backgroundColor?: string
|
||||
/** 透明度(遮罩模式) */
|
||||
opacity?: number
|
||||
/** 自定义类名 */
|
||||
customClass?: string
|
||||
/** z-index */
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
// 导入 Vue 的 Ref 类型
|
||||
import type { Ref } from 'vue'
|
@ -355,14 +355,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 评论统计 -->
|
||||
<div class="discussion-stats">
|
||||
<div class="discussion-stats" v-if="false">
|
||||
<span class="comment-count">评论 (1251)</span>
|
||||
</div>
|
||||
|
||||
<!-- 评论输入区域 -->
|
||||
<div class="comment-input-section">
|
||||
<div class="user-avatar">
|
||||
<img src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=40&q=80" alt="用户头像">
|
||||
<img :src="userAvatar" :alt="userName + '的头像'">
|
||||
</div>
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
@ -391,7 +391,7 @@
|
||||
<div class="discussion-list">
|
||||
<div v-for="comment in discussionList" :key="comment.id" class="discussion-item">
|
||||
<div class="comment-avatar">
|
||||
<img src="/images/activity/1.png" :alt="comment.username" />
|
||||
<img :src="comment.avatar || userAvatar" :alt="comment.username" />
|
||||
</div>
|
||||
<div class="comment-content">
|
||||
<div class="comment-header">
|
||||
@ -530,7 +530,7 @@
|
||||
<div class="post-comment-section">
|
||||
<div class="comment-input-wrapper">
|
||||
<div class="user-avatar">
|
||||
<img src="/images/activity/6.png" alt="用户头像" />
|
||||
<img :src="userAvatar" :alt="userName + '的头像'" />
|
||||
</div>
|
||||
<div class="comment-input-area">
|
||||
<textarea v-model="newComment" placeholder="写下你的评论..." class="comment-textarea"
|
||||
@ -582,6 +582,7 @@
|
||||
<div class="comment-content">
|
||||
<div class="comment-header">
|
||||
<span class="comment-username">{{ comment.username }}</span>
|
||||
<span class="comment-badge instructor" v-if="comment.isTeacher">讲师</span>
|
||||
</div>
|
||||
<div class="comment-text">{{ comment.content }}</div>
|
||||
<div class="comment-footer">
|
||||
@ -649,20 +650,22 @@
|
||||
<!-- 用户回复 -->
|
||||
<div class="reply-item user-reply">
|
||||
<div class="reply-avatar">
|
||||
<img src="/images/activity/7.png" alt="用户头像" />
|
||||
<img :src="userAvatar" :alt="userName + '的头像'" />
|
||||
</div>
|
||||
<div class="reply-content">
|
||||
<div class="reply-main">
|
||||
<div class="reply-header">
|
||||
<span class="reply-username">李同学</span>
|
||||
<span class="reply-badge user">学员</span>
|
||||
<span class="reply-username">{{ userName }}</span>
|
||||
<span class="reply-badge" :class="isTeacher ? 'instructor' : 'user'">
|
||||
{{ isTeacher ? '讲师' : '学员' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="reply-text">同意楼上的观点,这个课程确实很有帮助!</div>
|
||||
</div>
|
||||
<div class="reply-footer">
|
||||
<span class="reply-time">2025.07.23 18:15</span>
|
||||
<div class="reply-actions">
|
||||
<button class="reply-action-btn" @click="startReply(1, '李同学')">回复</button>
|
||||
<button class="reply-action-btn" @click="startReply(1, userName)">回复</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1348,6 +1351,7 @@ import { CourseApi } from '@/api/modules/course'
|
||||
import { CommentApi } from '@/api/modules/comment'
|
||||
import { AIApi } from '@/api/modules/ai'
|
||||
import { AuthApi } from '@/api/modules/auth'
|
||||
import Loading from '@/composables/useLoading'
|
||||
import type { Course, CourseSection, CourseComment } from '@/api/types'
|
||||
import QuillEditor from '@/components/common/QuillEditor.vue'
|
||||
import DPlayerVideo from '@/components/course/DPlayerVideo.vue'
|
||||
@ -1383,6 +1387,13 @@ const sectionsError = ref('')
|
||||
const isEnrolled = ref(false) // 用户是否已报名该课程
|
||||
const enrollmentLoading = ref(false) // 报名加载状态
|
||||
|
||||
// 用户信息
|
||||
const userInfo = ref<any>(null)
|
||||
const userAvatar = ref('https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=40&q=80')
|
||||
const userName = ref('用户')
|
||||
const userRoles = ref<string[]>([])
|
||||
const isTeacher = ref(false)
|
||||
|
||||
// 报名状态
|
||||
// const RegistrationStatus = ref(false)
|
||||
|
||||
@ -1672,7 +1683,8 @@ const displayComments = computed(() => {
|
||||
time: comment.timeAgo,
|
||||
content: comment.content,
|
||||
likes: comment.likeCount || 0,
|
||||
type: comment.isTop ? 'note' : 'comment'
|
||||
type: comment.isTop ? 'note' : 'comment',
|
||||
isTeacher: comment.userTag === 'teacher' || comment.userTag === '讲师' // 判断是否为讲师
|
||||
}))
|
||||
})
|
||||
|
||||
@ -1823,11 +1835,12 @@ const submitReply = () => {
|
||||
if (replyText.value.trim() && replyingTo.value) {
|
||||
const newReplyObj = {
|
||||
id: Date.now(),
|
||||
username: '当前用户',
|
||||
avatar: 'https://via.placeholder.com/40x40/1890ff/ffffff?text=我',
|
||||
username: userName.value,
|
||||
avatar: userAvatar.value,
|
||||
time: '刚刚',
|
||||
content: replyText.value,
|
||||
likes: 0
|
||||
likes: 0,
|
||||
isTeacher: isTeacher.value
|
||||
}
|
||||
|
||||
// 这里可以调用API提交回复
|
||||
@ -2443,13 +2456,23 @@ const handlePractice = async (section: CourseSection) => {
|
||||
exitDiscussion()
|
||||
}
|
||||
|
||||
// 显示 Loading
|
||||
Loading.show({
|
||||
text: '正在加载练习...',
|
||||
size: 'medium',
|
||||
color: '#1890ff'
|
||||
})
|
||||
|
||||
try {
|
||||
// 第一步:获取用户信息
|
||||
console.log('👤 获取用户信息...')
|
||||
Loading.updateText('正在获取用户信息...')
|
||||
|
||||
const userInfoResponse = await AuthApi.getUserInfo()
|
||||
|
||||
if (!userInfoResponse.success || !userInfoResponse.result?.baseInfo?.id) {
|
||||
console.error('❌ 获取用户信息失败:', userInfoResponse)
|
||||
Loading.hide()
|
||||
message.error('获取用户信息失败,请重新登录')
|
||||
return
|
||||
}
|
||||
@ -2458,6 +2481,7 @@ const handlePractice = async (section: CourseSection) => {
|
||||
console.log('✅ 获取用户信息成功,学生ID:', studentId)
|
||||
|
||||
// 第二步:调用章节练习API获取考试信息
|
||||
Loading.updateText('正在获取练习信息...')
|
||||
const response = await CourseApi.getSectionExercise(courseId.value, section.id.toString())
|
||||
|
||||
if (response.data && (response.data.code === 200 || response.data.code === 0)) {
|
||||
@ -2474,6 +2498,7 @@ const handlePractice = async (section: CourseSection) => {
|
||||
console.log('📋 开始获取考试题目,考试ID:', examId, '学生ID:', studentId)
|
||||
|
||||
// 第三步:根据考试ID和学生ID获取题目列表
|
||||
Loading.updateText('正在获取题目列表...')
|
||||
const questionsResponse = await CourseApi.getExamQuestions(examId, studentId)
|
||||
|
||||
if (questionsResponse.data && (questionsResponse.data.code === 200 || questionsResponse.data.code === 0)) {
|
||||
@ -2485,10 +2510,13 @@ const handlePractice = async (section: CourseSection) => {
|
||||
|
||||
// 第四步:根据每个题目ID获取详细信息
|
||||
const detailedQuestions = []
|
||||
const totalQuestions = questionsList.length
|
||||
|
||||
for (const questionItem of questionsList) {
|
||||
for (let i = 0; i < questionsList.length; i++) {
|
||||
const questionItem = questionsList[i]
|
||||
try {
|
||||
console.log('🔍 获取题目详情,题目ID:', questionItem.id)
|
||||
Loading.updateText(`正在获取题目详情... (${i + 1}/${totalQuestions})`)
|
||||
const detailResponse = await CourseApi.getQuestionDetail(questionItem.id)
|
||||
|
||||
if (detailResponse.data && (detailResponse.data.code === 200 || detailResponse.data.code === 0)) {
|
||||
@ -2567,16 +2595,22 @@ const handlePractice = async (section: CourseSection) => {
|
||||
|
||||
console.log('✅ 练习模式已启动,题目数量:', practiceQuestions.value.length)
|
||||
console.log('✅ 处理后的题目列表:', practiceQuestions.value)
|
||||
|
||||
// 隐藏 Loading,显示练习界面
|
||||
Loading.hide()
|
||||
} else {
|
||||
console.warn('⚠️ 没有获取到有效的题目详情')
|
||||
Loading.hide()
|
||||
message.warning('没有获取到有效的题目详情')
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ 考试题目列表为空:', questionsList)
|
||||
Loading.hide()
|
||||
message.warning('考试题目列表为空')
|
||||
}
|
||||
} else {
|
||||
console.error('❌ 获取考试题目失败:', questionsResponse.data?.message || questionsResponse.message)
|
||||
Loading.hide()
|
||||
message.error(questionsResponse.data?.message || questionsResponse.message || '获取考试题目失败')
|
||||
}
|
||||
} else {
|
||||
@ -2924,12 +2958,13 @@ const submitDiscussionComment = () => {
|
||||
|
||||
const comment = {
|
||||
id: Date.now(),
|
||||
username: '当前用户',
|
||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=60&q=80',
|
||||
username: userName.value,
|
||||
avatar: userAvatar.value,
|
||||
content: newComment.value,
|
||||
time: new Date().toLocaleString(),
|
||||
likes: 0,
|
||||
isLiked: false, // 新评论默认未点赞
|
||||
isTeacher: isTeacher.value, // 添加讲师标识
|
||||
replies: []
|
||||
}
|
||||
|
||||
@ -3106,10 +3141,26 @@ const handleImageError = (event: Event) => {
|
||||
}
|
||||
|
||||
// 处理课程报名
|
||||
const handleEnrollCourse = (course: any) => {
|
||||
const handleEnrollCourse = (course: any, _event?: Event) => {
|
||||
console.log('点击报名课程:', course)
|
||||
// 这里可以添加报名逻辑,比如跳转到课程详情页
|
||||
window.open(`/course/${course.id}`, '_blank')
|
||||
|
||||
// 显示 Loading
|
||||
Loading.show({
|
||||
text: '正在跳转...',
|
||||
size: 'medium',
|
||||
color: '#1890ff'
|
||||
})
|
||||
|
||||
// 跳转到 AI Companion 页面
|
||||
const targetUrl = `http://localhost:3000/ai-companion?courseId=${course.id}`
|
||||
console.log('跳转到报名页面:', targetUrl)
|
||||
|
||||
// 模拟短暂延迟,让用户看到 Loading 效果
|
||||
setTimeout(() => {
|
||||
window.open(targetUrl, '_blank')
|
||||
// 隐藏 Loading
|
||||
Loading.hide()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 处理课程报名
|
||||
@ -3562,8 +3613,36 @@ const scrollToBottom = () => {
|
||||
|
||||
|
||||
|
||||
// 获取用户信息
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
console.log('🔍 获取用户信息...')
|
||||
const response = await AuthApi.getUserInfo()
|
||||
|
||||
if (response.success && response.result?.baseInfo) {
|
||||
userInfo.value = response.result
|
||||
userAvatar.value = response.result.baseInfo.avatar || userAvatar.value
|
||||
userName.value = response.result.baseInfo.realname || response.result.baseInfo.username || '用户'
|
||||
userRoles.value = response.result.roles || []
|
||||
isTeacher.value = userRoles.value.includes('teacher')
|
||||
|
||||
console.log('✅ 用户信息获取成功:', {
|
||||
avatar: userAvatar.value,
|
||||
name: userName.value,
|
||||
roles: userRoles.value,
|
||||
isTeacher: isTeacher.value
|
||||
})
|
||||
} else {
|
||||
console.warn('⚠️ 获取用户信息失败:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 获取用户信息异常:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('课程详情页加载完成,课程ID:', courseId.value)
|
||||
loadUserInfo() // 加载用户信息
|
||||
loadCourseDetail()
|
||||
loadCourseSections()
|
||||
loadCourseComments() // 加载评论
|
||||
|
Loading…
x
Reference in New Issue
Block a user