模板代码
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/dist
|
||||
80
index.html
Normal file
80
index.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>后台管理系统</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #409eff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2802
package-lock.json
generated
Normal file
2802
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "admin-system",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.4.3",
|
||||
"element-plus": "^2.4.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.11",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"sass": "^1.69.5",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
public/logo.svg
Normal file
6
public/logo.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#409EFF"/>
|
||||
<path d="M32 16C40.8366 16 48 23.1634 48 32C48 40.8366 40.8366 48 32 48C23.1634 48 16 40.8366 16 32C16 23.1634 23.1634 16 32 16Z" fill="white"/>
|
||||
<path d="M28 24H36V28H32V36H28V24Z" fill="#409EFF"/>
|
||||
<path d="M28 38H36V42H28V38Z" fill="#409EFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 409 B |
237
src/App.vue
Normal file
237
src/App.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 检查用户登录状态
|
||||
userStore.checkAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
.el-menu {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.el-menu-item:hover {
|
||||
background-color: #ecf5ff !important;
|
||||
color: #409eff !important;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
background-color: #409eff !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.el-submenu__title:hover {
|
||||
background-color: #ecf5ff !important;
|
||||
color: #409eff !important;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-select .el-input__wrapper {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 自定义按钮样式 */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #409eff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.empty-state .el-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
background: #ffffff;
|
||||
color: white;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.stat-card .stat-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-card .stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.el-aside {
|
||||
width: 200px !important;
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-30px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
298
src/components/Captcha.vue
Normal file
298
src/components/Captcha.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="captcha-container">
|
||||
<div class="captcha-input-group">
|
||||
<el-input
|
||||
v-model="captchaInput"
|
||||
:placeholder="placeholder"
|
||||
:size="size"
|
||||
clearable
|
||||
@keyup.enter="$emit('verify', { captchaId, captchaText: captchaInput })"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<div class="captcha-image-wrapper" @click="refreshCaptcha">
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="captcha-image"
|
||||
/>
|
||||
<div v-else class="captcha-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div class="captcha-refresh-hint">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>点击刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="captcha-actions">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="refreshCaptcha"
|
||||
:loading="loading"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新验证码
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Loading } from '@element-plus/icons-vue'
|
||||
import api from '@/utils/api'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入验证码'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'large'
|
||||
},
|
||||
autoRefresh: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'verify', 'refresh'])
|
||||
|
||||
// 响应式数据
|
||||
const captchaInput = ref(props.modelValue)
|
||||
const captchaImage = ref('')
|
||||
const captchaId = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
const getCaptcha = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.get('/captcha/generate')
|
||||
|
||||
if (response.data.success) {
|
||||
captchaImage.value = response.data.data.image
|
||||
captchaId.value = response.data.data.captchaId
|
||||
emit('refresh', { captchaId: captchaId.value })
|
||||
} else {
|
||||
ElMessage.error(response.data.message || '获取验证码失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
ElMessage.error('获取验证码失败,请检查网络连接')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新验证码
|
||||
*/
|
||||
const refreshCaptcha = async () => {
|
||||
captchaInput.value = ''
|
||||
emit('update:modelValue', '')
|
||||
await getCaptcha()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理输入变化
|
||||
*/
|
||||
const handleInput = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* @param {string} inputText 用户输入的验证码
|
||||
* @returns {Promise<boolean>} 验证结果
|
||||
*/
|
||||
const verifyCaptcha = async (inputText) => {
|
||||
if (!captchaId.value) {
|
||||
ElMessage.error('请先获取验证码')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!inputText || inputText.trim() === '') {
|
||||
ElMessage.error('请输入验证码')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post('/captcha/verify', {
|
||||
captchaId: captchaId.value,
|
||||
captchaText: inputText.trim()
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
return true
|
||||
} else {
|
||||
ElMessage.error(response.data.message || '验证码错误')
|
||||
// 验证失败后刷新验证码
|
||||
await refreshCaptcha()
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证验证码失败:', error)
|
||||
ElMessage.error('验证验证码失败,请重试')
|
||||
await refreshCaptcha()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前验证码信息
|
||||
* @returns {Object} 验证码信息
|
||||
*/
|
||||
const getCaptchaInfo = () => {
|
||||
return {
|
||||
captchaId: captchaId.value,
|
||||
captchaText: captchaInput.value
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
captchaInput.value = newValue
|
||||
})
|
||||
|
||||
// 组件挂载时获取验证码
|
||||
onMounted(() => {
|
||||
if (props.autoRefresh) {
|
||||
getCaptcha()
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refreshCaptcha,
|
||||
verifyCaptcha,
|
||||
getCaptchaInfo
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha-input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-input-group .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 1px #409eff;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.captcha-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.captcha-loading .el-icon {
|
||||
font-size: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.captcha-refresh-hint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper:hover .captcha-refresh-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.captcha-refresh-hint .el-icon {
|
||||
font-size: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.captcha-actions {
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.captcha-actions .el-button {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.captcha-actions .el-button:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.captcha-actions .el-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.captcha-input-group {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.captcha-image-wrapper {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
350
src/components/HorizontalImageDisplay.vue
Normal file
350
src/components/HorizontalImageDisplay.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="horizontal-image-display">
|
||||
<div class="image-list">
|
||||
<div
|
||||
v-for="(image, index) in images"
|
||||
:key="index"
|
||||
class="image-item"
|
||||
>
|
||||
<img :src="getImageUrl(image)" @click="handlePreview(image)" />
|
||||
<div class="image-actions">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
circle
|
||||
@click="handleRemove(index)"
|
||||
class="remove-btn"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="images.length < maxCount"
|
||||
class="upload-item"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div class="upload-text">{{ placeholder }}</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptTypes"
|
||||
multiple
|
||||
@change="handleFileChange"
|
||||
class="file-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<el-dialog v-model="previewVisible" title="预览" width="60%">
|
||||
<div class="preview-container">
|
||||
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-image" />
|
||||
<video v-else-if="previewType === 'video'" :src="previewUrl" controls class="preview-video" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Delete } from '@element-plus/icons-vue'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '点击上传图片'
|
||||
},
|
||||
maxCount: {
|
||||
type: Number,
|
||||
default: 9
|
||||
},
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 10 // MB
|
||||
},
|
||||
allowVideo: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowedFileTypes: {
|
||||
type: String,
|
||||
default: '' // 例如:'.jpg,.png',为空时使用默认值
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
|
||||
|
||||
const images = ref([])
|
||||
const fileInput = ref(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref('image')
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal && Array.isArray(newVal)) {
|
||||
images.value = [...newVal]
|
||||
} else {
|
||||
images.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = computed(() => {
|
||||
return (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000') + '/api/upload'
|
||||
})
|
||||
|
||||
const acceptTypes = computed(() => {
|
||||
// 如果提供了自定义文件类型限制,则使用它
|
||||
if (props.allowedFileTypes) {
|
||||
return props.allowedFileTypes
|
||||
}
|
||||
|
||||
// 否则使用默认值
|
||||
const imageTypes = '.jpg,.jpeg,.png,.gif,.webp'
|
||||
const videoTypes = '.mp4,.avi,.mov,.wmv,.flv'
|
||||
return props.allowVideo ? `${imageTypes},${videoTypes}` : imageTypes
|
||||
})
|
||||
|
||||
// 触发文件选择
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value.click()
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = async (event) => {
|
||||
const files = event.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
// 检查文件数量
|
||||
if (images.value.length + files.length > props.maxCount) {
|
||||
ElMessage.error(`最多只能上传${props.maxCount}个文件`)
|
||||
return
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
|
||||
// 检查文件大小
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
|
||||
if (!isLtMaxSize) {
|
||||
ElMessage.error(`文件大小不能超过${props.maxSize}MB`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isVideo = file.type.startsWith('video/')
|
||||
|
||||
// 如果设置了自定义文件类型限制,则检查文件扩展名是否符合要求
|
||||
if (props.allowedFileTypes) {
|
||||
const fileExt = `.${file.name.split('.').pop().toLowerCase()}`
|
||||
const allowedExts = props.allowedFileTypes.split(',')
|
||||
|
||||
if (!allowedExts.some(ext => ext.trim().toLowerCase() === fileExt)) {
|
||||
ElMessage.error(`只能上传${props.allowedFileTypes}格式的文件`)
|
||||
continue
|
||||
}
|
||||
} else if (!isImage && (!props.allowVideo || !isVideo)) {
|
||||
// 使用默认的文件类型检查
|
||||
const allowedTypes = props.allowVideo ? '图片或视频' : '图片'
|
||||
ElMessage.error(`只能上传${allowedTypes}文件`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
await uploadFile(file)
|
||||
}
|
||||
|
||||
// 重置文件输入
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
const uploadFile = async (file) => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
// 获取token
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(uploadUrl.value, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 处理上传成功
|
||||
handleUploadSuccess(result)
|
||||
} else {
|
||||
// 处理上传失败
|
||||
handleUploadError(new Error(result.message || '上传失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
handleUploadError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传成功
|
||||
const handleUploadSuccess = (response) => {
|
||||
if (response.success && response.data) {
|
||||
let url = ''
|
||||
|
||||
if (response.data.url) {
|
||||
// 返回相对路径,去掉前缀
|
||||
url = response.data.path
|
||||
} else if (response.data.path && Array.isArray(response.data.path)) {
|
||||
// 处理多文件上传返回的URLs数组
|
||||
url = response.data.path[0]
|
||||
}
|
||||
|
||||
if (url) {
|
||||
console.log(url);
|
||||
images.value.push(url)
|
||||
emit('update:modelValue', [...images.value])
|
||||
emit('upload-success', response.data)
|
||||
ElMessage.success('上传成功')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传失败
|
||||
const handleUploadError = (error) => {
|
||||
console.error('上传失败:', error)
|
||||
ElMessage.error('上传失败,请重试')
|
||||
emit('upload-error', error)
|
||||
}
|
||||
|
||||
// 移除图片
|
||||
const handleRemove = (index) => {
|
||||
images.value.splice(index, 1)
|
||||
emit('update:modelValue', [...images.value])
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const handlePreview = (url) => {
|
||||
if (!url) return
|
||||
|
||||
previewUrl.value = getImageUrl(url)
|
||||
|
||||
// 判断文件类型
|
||||
const ext = url.split('.').pop().toLowerCase()
|
||||
previewType.value = ['mp4', 'avi', 'mov', 'wmv', 'flv'].includes(ext) ? 'video' : 'image'
|
||||
|
||||
previewVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.horizontal-image-display {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid #e0e0e0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.image-item:hover .image-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
background-color: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s, background-color 0.3s;
|
||||
}
|
||||
|
||||
.upload-item:hover {
|
||||
border-color: #409eff;
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: #8c939d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
</style>
|
||||
355
src/components/ImageUpload.vue
Normal file
355
src/components/ImageUpload.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="image-upload">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="uploadData"
|
||||
:before-upload="beforeUpload"
|
||||
:on-success="handleSuccess"
|
||||
:on-error="handleError"
|
||||
:on-progress="handleProgress"
|
||||
:show-file-list="false"
|
||||
:auto-upload="true"
|
||||
accept="image/*"
|
||||
class="upload-container"
|
||||
>
|
||||
<div class="upload-area" :class="{ 'uploading': uploading, 'has-image': imageUrl }">
|
||||
<div v-if="!imageUrl && !uploading" class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div class="upload-text">{{ placeholder }}</div>
|
||||
<div class="upload-hint">支持 JPG、PNG 格式,大小不超过 5MB</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploading" class="upload-progress">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<div class="progress-text">上传中... {{ uploadProgress }}%</div>
|
||||
<el-progress :percentage="uploadProgress" :show-text="false" />
|
||||
</div>
|
||||
|
||||
<div v-if="imageUrl && !uploading" class="image-preview">
|
||||
<img :src="imageUrl" :alt="placeholder" class="preview-image" />
|
||||
<div class="image-overlay">
|
||||
<el-button type="primary" size="small" @click.stop="previewImage">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click.stop="removeImage">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<!-- 图片预览对话框 -->
|
||||
<el-dialog v-model="previewVisible" title="图片预览" width="60%" center>
|
||||
<div class="preview-dialog">
|
||||
<img :src="imageUrl" :alt="placeholder" class="preview-dialog-image" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Loading, ZoomIn, Delete } from '@element-plus/icons-vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '点击上传图片'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
|
||||
|
||||
// 响应式数据
|
||||
const uploadRef = ref()
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const imageUrl = ref(props.modelValue)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = '/api/upload/image'
|
||||
const uploadHeaders = computed(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
const uploadData = { type: 'product' }
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
imageUrl.value = newValue
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传前验证
|
||||
*/
|
||||
const beforeUpload = (file) => {
|
||||
if (props.disabled) {
|
||||
ElMessage.warning('当前状态下不允许上传')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件大小(5MB)
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传进度
|
||||
*/
|
||||
const handleProgress = (event) => {
|
||||
uploadProgress.value = Math.round(event.percent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传成功
|
||||
*/
|
||||
const handleSuccess = (response) => {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 0
|
||||
|
||||
if (response.success) {
|
||||
imageUrl.value = response.data.url
|
||||
emit('update:modelValue', response.data.url)
|
||||
emit('upload-success', response.data)
|
||||
ElMessage.success('图片上传成功')
|
||||
} else {
|
||||
ElMessage.error(response.message || '上传失败')
|
||||
emit('upload-error', response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传失败
|
||||
*/
|
||||
const handleError = (error) => {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 0
|
||||
console.error('上传失败:', error)
|
||||
ElMessage.error('图片上传失败,请重试')
|
||||
emit('upload-error', error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
*/
|
||||
const previewImage = () => {
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除图片
|
||||
*/
|
||||
const removeImage = () => {
|
||||
imageUrl.value = ''
|
||||
emit('update:modelValue', '')
|
||||
ElMessage.success('图片已删除')
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发上传
|
||||
*/
|
||||
const triggerUpload = () => {
|
||||
if (!props.disabled) {
|
||||
uploadRef.value?.$el.querySelector('input').click()
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
triggerUpload
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 240px;
|
||||
height: 160px;
|
||||
border: 2px dashed #e4e7ed;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #409eff;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e6f4ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.upload-area.uploading {
|
||||
border-color: #409eff;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e6f4ff 100%);
|
||||
cursor: not-allowed;
|
||||
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.upload-area.has-image {
|
||||
border-color: #409eff;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
text-align: center;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 36px;
|
||||
color: #409eff;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
font-size: 24px;
|
||||
color: #409eff;
|
||||
margin-bottom: 8px;
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.image-preview:hover .image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-dialog {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-dialog-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.upload-area {
|
||||
width: 180px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
295
src/components/MediaUpload.vue
Normal file
295
src/components/MediaUpload.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="media-upload">
|
||||
<div class="upload-area">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:file-list="fileList"
|
||||
:on-success="handleUploadSuccess"
|
||||
:on-error="handleUploadError"
|
||||
:on-remove="handleRemove"
|
||||
:on-preview="handlePreview"
|
||||
:before-upload="beforeUpload"
|
||||
:accept="acceptTypes"
|
||||
multiple
|
||||
list-type="picture-card"
|
||||
class="media-uploader"
|
||||
>
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div class="upload-text">{{ placeholder }}</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<el-dialog v-model="previewVisible" title="预览" width="60%">
|
||||
<div class="preview-container">
|
||||
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-image" />
|
||||
<video v-else-if="previewType === 'video'" :src="previewUrl" controls class="preview-video" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '点击上传媒体文件'
|
||||
},
|
||||
maxCount: {
|
||||
type: Number,
|
||||
default: 9
|
||||
},
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 10 // MB
|
||||
},
|
||||
allowVideo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
|
||||
|
||||
const uploadRef = ref()
|
||||
const fileList = ref([])
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref('image')
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = computed(() => {
|
||||
return (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000') + '/api/upload'
|
||||
})
|
||||
|
||||
const uploadHeaders = computed(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
|
||||
const acceptTypes = computed(() => {
|
||||
const imageTypes = '.jpg,.jpeg,.png,.gif,.webp'
|
||||
const videoTypes = '.mp4,.avi,.mov,.wmv,.flv'
|
||||
return props.allowVideo ? `${imageTypes},${videoTypes}` : imageTypes
|
||||
})
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal && Array.isArray(newVal)) {
|
||||
fileList.value = newVal.map((url, index) => ({
|
||||
uid: index,
|
||||
name: `file-${index}`,
|
||||
status: 'success',
|
||||
url: getImageUrl(url),
|
||||
response: { data: { url } }
|
||||
}))
|
||||
} else {
|
||||
fileList.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 上传前检查
|
||||
const beforeUpload = (file) => {
|
||||
// 检查文件数量
|
||||
if (fileList.value.length >= props.maxCount) {
|
||||
ElMessage.error(`最多只能上传${props.maxCount}个文件`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
|
||||
if (!isLtMaxSize) {
|
||||
ElMessage.error(`文件大小不能超过${props.maxSize}MB`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isVideo = file.type.startsWith('video/')
|
||||
|
||||
if (!isImage && (!props.allowVideo || !isVideo)) {
|
||||
const allowedTypes = props.allowVideo ? '图片或视频' : '图片'
|
||||
ElMessage.error(`只能上传${allowedTypes}文件`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 上传成功
|
||||
const handleUploadSuccess = (response, file) => {
|
||||
console.log('上传响应:', response)
|
||||
if (response.success && response.data) {
|
||||
// 更新文件列表中当前文件的响应数据
|
||||
const currentFileIndex = fileList.value.findIndex(item => item.uid === file.uid)
|
||||
if (currentFileIndex !== -1) {
|
||||
fileList.value[currentFileIndex].response = response
|
||||
}
|
||||
|
||||
// 收集所有已上传文件的URL
|
||||
const urls = fileList.value
|
||||
.filter(item => item.status === 'success')
|
||||
.map(item => {
|
||||
if (item.response?.data?.url) {
|
||||
// 返回相对路径,去掉前缀
|
||||
const url = item.response.data.url
|
||||
return url.replace(/^.*\/uploads\//, '')
|
||||
}
|
||||
if (item.response?.data?.urls && Array.isArray(item.response.data.urls)) {
|
||||
// 处理多文件上传返回的URLs数组
|
||||
return item.response.data.urls.map(url => url.replace(/^.*\/uploads\//, ''))
|
||||
}
|
||||
return item.url?.replace(/^.*\/uploads\//, '') || ''
|
||||
})
|
||||
.flat() // 展平数组,处理多文件情况
|
||||
.filter(url => url) // 过滤空值
|
||||
|
||||
emit('update:modelValue', urls)
|
||||
emit('upload-success', response.data)
|
||||
ElMessage.success('上传成功')
|
||||
} else {
|
||||
console.error('上传失败响应:', response)
|
||||
ElMessage.error(response.message || '上传失败')
|
||||
handleUploadError(new Error(response.message || '上传失败'), file)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传失败
|
||||
const handleUploadError = (error, file) => {
|
||||
console.error('上传失败:', error)
|
||||
ElMessage.error('上传失败,请重试')
|
||||
emit('upload-error', error)
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
const handleRemove = (file) => {
|
||||
const urls = fileList.value
|
||||
.filter(item => item.uid !== file.uid)
|
||||
.map(item => {
|
||||
if (item.response?.data?.url) {
|
||||
// 移除API基础URL,只保留相对路径
|
||||
return item.response.data.url.replace(/^.*\/uploads\//, '')
|
||||
}
|
||||
return item.url?.replace(/^.*\/uploads\//, '') || ''
|
||||
})
|
||||
.filter(url => url) // 过滤空值
|
||||
|
||||
emit('update:modelValue', urls)
|
||||
}
|
||||
|
||||
// 预览文件
|
||||
const handlePreview = (file) => {
|
||||
const url = file.response?.data?.url || file.url
|
||||
if (!url) return
|
||||
|
||||
previewUrl.value = getImageUrl(url)
|
||||
|
||||
// 判断文件类型
|
||||
if (file.name) {
|
||||
const ext = file.name.split('.').pop().toLowerCase()
|
||||
previewType.value = ['mp4', 'avi', 'mov', 'wmv', 'flv'].includes(ext) ? 'video' : 'image'
|
||||
} else {
|
||||
previewType.value = 'image'
|
||||
}
|
||||
|
||||
previewVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-uploader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin-top: 8px;
|
||||
color: #8c939d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
/* 完全重写横向排列样式 */
|
||||
.media-uploader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-uploader :deep(.el-upload-list) {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 8px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.media-uploader :deep(.el-upload-list--picture-card) {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 8px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.media-uploader :deep(.el-upload-list--picture-card .el-upload-list__item) {
|
||||
width: 104px !important;
|
||||
height: 104px !important;
|
||||
margin: 0 !important;
|
||||
display: inline-flex !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.media-uploader :deep(.el-upload--picture-card) {
|
||||
width: 104px !important;
|
||||
height: 104px !important;
|
||||
margin: 0 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* 覆盖可能的默认样式 */
|
||||
.media-uploader :deep(.el-upload-list__item) {
|
||||
float: none !important;
|
||||
clear: none !important;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
250
src/components/RichTextEditor.vue
Normal file
250
src/components/RichTextEditor.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="rich-text-editor">
|
||||
<Toolbar
|
||||
style="border-bottom: 1px solid #ccc"
|
||||
:editor="editorRef"
|
||||
:defaultConfig="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<Editor
|
||||
style="height: 500px; overflow-y: hidden;"
|
||||
v-model="valueHtml"
|
||||
:defaultConfig="editorConfig"
|
||||
:mode="mode"
|
||||
@onCreated="handleCreated"
|
||||
@onChange="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入内容...'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '500px'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
// 编辑器实例,必须用 shallowRef
|
||||
const editorRef = shallowRef()
|
||||
|
||||
// 内容 HTML
|
||||
const valueHtml = ref('')
|
||||
|
||||
// 模式
|
||||
const mode = 'default' // 或 'simple'
|
||||
|
||||
// 工具栏配置
|
||||
const toolbarConfig = {
|
||||
excludeKeys: [
|
||||
'group-video', // 排除视频
|
||||
'fullScreen' // 排除全屏
|
||||
]
|
||||
}
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig = {
|
||||
placeholder: props.placeholder,
|
||||
MENU_CONF: {
|
||||
// 配置上传图片
|
||||
uploadImage: {
|
||||
server: '/api/upload/image',
|
||||
fieldName: 'file',
|
||||
meta: {
|
||||
token: localStorage.getItem('token') || ''
|
||||
},
|
||||
metaWithUrl: false,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
|
||||
},
|
||||
maxFileSize: 5 * 1024 * 1024, // 5M
|
||||
allowedFileTypes: ['image/*'],
|
||||
customInsert(res, insertFn) {
|
||||
// 自定义插入图片
|
||||
if (res.code === 200) {
|
||||
insertFn(res.data.url, res.data.alt || '', res.data.url)
|
||||
} else {
|
||||
console.error('图片上传失败:', res.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
// 配置字体
|
||||
fontSize: {
|
||||
fontSizeList: [
|
||||
'12px', '13px', '14px', '15px', '16px', '17px', '18px', '19px', '20px',
|
||||
'22px', '24px', '26px', '28px', '30px', '32px', '34px', '36px'
|
||||
]
|
||||
},
|
||||
// 配置字体族
|
||||
fontFamily: {
|
||||
fontFamilyList: [
|
||||
'黑体', '仿宋', '楷体', '标楷体', '华文仿宋', '华文楷体', '宋体', '微软雅黑',
|
||||
'Arial', 'Tahoma', 'Verdana', 'Times New Roman', 'Courier New'
|
||||
]
|
||||
},
|
||||
// 配置颜色
|
||||
color: {
|
||||
colors: [
|
||||
'#000000', '#333333', '#666666', '#999999', '#cccccc', '#ffffff',
|
||||
'#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#0000ff',
|
||||
'#9900ff', '#ff00ff', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3',
|
||||
'#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc'
|
||||
]
|
||||
},
|
||||
// 配置背景色
|
||||
bgColor: {
|
||||
colors: [
|
||||
'#000000', '#333333', '#666666', '#999999', '#cccccc', '#ffffff',
|
||||
'#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#0000ff',
|
||||
'#9900ff', '#ff00ff', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3',
|
||||
'#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor // 记录 editor 实例,重要!
|
||||
}
|
||||
|
||||
const handleChange = (editor) => {
|
||||
emit('update:modelValue', valueHtml.value)
|
||||
emit('change', valueHtml.value)
|
||||
}
|
||||
|
||||
// 监听外部传入的值变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal !== valueHtml.value) {
|
||||
valueHtml.value = newVal
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听内部值变化
|
||||
watch(valueHtml, (newVal) => {
|
||||
if (newVal !== props.modelValue) {
|
||||
emit('update:modelValue', newVal)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-editor {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.rich-text-editor:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.rich-text-editor:focus-within {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 工具栏样式优化 */
|
||||
:deep(.w-e-toolbar) {
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
border-bottom: 1px solid rgba(102, 126, 234, 0.1) !important;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* 编辑器内容区域样式 */
|
||||
:deep(.w-e-text-container) {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
:deep(.w-e-text-placeholder) {
|
||||
color: #a0a0a0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 适配H5端的样式 */
|
||||
:deep(.w-e-text-container .w-e-text) {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text h1,
|
||||
.w-e-text-container .w-e-text h2,
|
||||
.w-e-text-container .w-e-text h3,
|
||||
.w-e-text-container .w-e-text h4,
|
||||
.w-e-text-container .w-e-text h5,
|
||||
.w-e-text-container .w-e-text h6) {
|
||||
margin: 16px 0 8px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text ul,
|
||||
.w-e-text-container .w-e-text ol) {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text blockquote) {
|
||||
border-left: 4px solid #667eea;
|
||||
padding-left: 16px;
|
||||
margin: 16px 0;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text table td,
|
||||
.w-e-text-container .w-e-text table th) {
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text table th) {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
585
src/layout/Layout.vue
Normal file
585
src/layout/Layout.vue
Normal file
@@ -0,0 +1,585 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="isCollapse ? '64px' : '200px'" class="sidebar">
|
||||
<div class="logo">
|
||||
<img v-if="!isCollapse" src="/logo.svg" alt="Logo" class="logo-img" />
|
||||
<span v-if="!isCollapse" class="logo-text">后台管理</span>
|
||||
<el-icon v-else class="logo-icon"><Setting /></el-icon>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
:collapse="isCollapse"
|
||||
:unique-opened="true"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<template #title>仪表盘</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<template #title>用户管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/user-audit">
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
<template #title>用户审核</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/points">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<template #title>积分管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/transfers">
|
||||
<el-icon><Money /></el-icon>
|
||||
<template #title>转账管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/beans">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<template #title>融豆管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/daily-transfer-stats">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<template #title>昨日转账统计</template>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- <el-menu-item v-if="userStore.isAdmin" index="/matching-management">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<template #title>匹配管理</template>
|
||||
</el-menu-item> -->
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/agents">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<template #title>代理管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/announcements">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<template #title>通知公告</template>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- <el-menu-item v-if="userStore.isAdmin" index="/database-monitor">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<template #title>数据库监控</template>
|
||||
</el-menu-item> -->
|
||||
|
||||
<el-menu-item index="/profile">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<template #title>个人资料</template>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- <el-menu-item v-if="userStore.isAdmin" index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<template #title>系统设置</template>
|
||||
</el-menu-item> -->
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-container>
|
||||
<!-- 顶部导航 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
type="text"
|
||||
@click="toggleCollapse"
|
||||
class="collapse-btn"
|
||||
>
|
||||
<el-icon><Expand v-if="isCollapse" /><Fold v-else /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item
|
||||
v-for="item in breadcrumbs"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
>
|
||||
{{ item.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 全屏按钮 -->
|
||||
<el-tooltip content="全屏" placement="bottom">
|
||||
<el-button type="text" @click="toggleFullscreen" class="header-btn">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<el-tooltip content="刷新" placement="bottom">
|
||||
<el-button type="text" @click="refresh" class="header-btn">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<el-dropdown @command="handleCommand" class="user-dropdown">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="userStore.user?.avatar">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="username">{{ userStore.user?.username }}</span>
|
||||
<el-icon class="dropdown-icon"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
个人资料
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="changePassword">
|
||||
<el-icon><Lock /></el-icon>
|
||||
修改密码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<el-main class="main-content">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog
|
||||
v-model="passwordDialogVisible"
|
||||
title="修改密码"
|
||||
width="400px"
|
||||
:before-close="handlePasswordDialogClose"
|
||||
>
|
||||
<el-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="当前密码" prop="currentPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请确认新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="passwordDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleChangePassword" :loading="userStore.loading">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Odometer,
|
||||
User,
|
||||
UserFilled,
|
||||
Setting,
|
||||
Expand,
|
||||
Fold,
|
||||
FullScreen,
|
||||
Refresh,
|
||||
ArrowDown,
|
||||
Lock,
|
||||
SwitchButton,
|
||||
Money,
|
||||
Ticket,
|
||||
Warning,
|
||||
Goods,
|
||||
List,
|
||||
Coin,
|
||||
DocumentChecked,
|
||||
Avatar,
|
||||
DataAnalysis,
|
||||
Monitor,
|
||||
Connection,
|
||||
CreditCard,
|
||||
Bell
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 组件挂载时不再自动验证token,避免登录后立即触发401错误
|
||||
// token验证交给具体的API调用时处理
|
||||
onMounted(() => {
|
||||
// 仅确保用户状态已正确加载
|
||||
console.log('Layout组件已挂载,用户状态:', userStore.isAuthenticated)
|
||||
})
|
||||
|
||||
// 侧边栏折叠状态
|
||||
const isCollapse = ref(false)
|
||||
|
||||
// 修改密码对话框
|
||||
const passwordDialogVisible = ref(false)
|
||||
const passwordFormRef = ref()
|
||||
const passwordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 密码验证规则
|
||||
const passwordRules = {
|
||||
currentPassword: [
|
||||
{ required: true, message: '请输入当前密码', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{6,}$/,
|
||||
message: '密码必须包含大小写字母和数字',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== passwordForm.value.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 面包屑导航
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched.filter(item => item.meta && item.meta.title)
|
||||
const breadcrumbList = []
|
||||
|
||||
matched.forEach(item => {
|
||||
if (item.path !== '/') {
|
||||
breadcrumbList.push({
|
||||
title: item.meta.title.replace(' - 后台管理系统', ''),
|
||||
path: item.path
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return breadcrumbList
|
||||
})
|
||||
|
||||
// 切换侧边栏折叠
|
||||
const toggleCollapse = () => {
|
||||
isCollapse.value = !isCollapse.value
|
||||
}
|
||||
|
||||
// 全屏切换
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新页面
|
||||
const refresh = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// 处理用户菜单命令
|
||||
const handleCommand = (command) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'changePassword':
|
||||
passwordDialogVisible.value = true
|
||||
break
|
||||
case 'logout':
|
||||
handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const handleChangePassword = async () => {
|
||||
try {
|
||||
await passwordFormRef.value.validate()
|
||||
|
||||
const result = await userStore.changePassword({
|
||||
currentPassword: passwordForm.value.currentPassword,
|
||||
newPassword: passwordForm.value.newPassword
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
passwordDialogVisible.value = false
|
||||
resetPasswordForm()
|
||||
|
||||
// 延迟跳转到登录页
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('密码修改失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭密码对话框
|
||||
const handlePasswordDialogClose = () => {
|
||||
resetPasswordForm()
|
||||
passwordDialogVisible.value = false
|
||||
}
|
||||
|
||||
// 重置密码表单
|
||||
const resetPasswordForm = () => {
|
||||
passwordForm.value = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
passwordFormRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #304156;
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar .el-menu {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.sidebar .el-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar .el-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(144, 147, 153, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar .el-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(144, 147, 153, 0.5);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #2b3a4b;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
background-color: #304156;
|
||||
}
|
||||
|
||||
.sidebar-menu .el-menu-item,
|
||||
.sidebar-menu .el-sub-menu__title {
|
||||
color: #bfcbd9;
|
||||
border-bottom: 1px solid #434a50;
|
||||
}
|
||||
|
||||
.sidebar-menu .el-menu-item:hover,
|
||||
.sidebar-menu .el-sub-menu__title:hover {
|
||||
background-color: #434a50 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .el-menu-item.is-active {
|
||||
background-color: #409eff !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
margin-right: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
src/main.js
Normal file
53
src/main.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useUserStore } from './stores/user'
|
||||
import './style.css'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({
|
||||
showSpinner: false,
|
||||
minimum: 0.2,
|
||||
easing: 'ease',
|
||||
speed: 500
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
// 全局注册dayjs
|
||||
app.config.globalProperties.$dayjs = dayjs
|
||||
|
||||
// 应用初始化后检查用户状态
|
||||
app.mount('#app')
|
||||
|
||||
// 初始化用户状态检查
|
||||
const userStore = useUserStore()
|
||||
if (userStore.isAuthenticated) {
|
||||
// 如果用户已登录,启动状态检查
|
||||
userStore.checkAuth().then((isValid) => {
|
||||
if (isValid) {
|
||||
userStore.startStatusCheck()
|
||||
}
|
||||
})
|
||||
}
|
||||
221
src/router/index.js
Normal file
221
src/router/index.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import NProgress from 'nprogress'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录 - 炬融圈',
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layout/Layout.vue'),
|
||||
redirect: '/dashboard',
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: {
|
||||
title: '仪表盘 - 炬融圈',
|
||||
icon: 'Odometer'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/Users.vue'),
|
||||
meta: {
|
||||
title: '用户管理 - 炬融圈',
|
||||
icon: 'User',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'user-audit',
|
||||
name: 'UserAudit',
|
||||
component: () => import('@/views/UserAudit.vue'),
|
||||
meta: {
|
||||
title: '用户审核 - 炬融圈',
|
||||
icon: 'DocumentChecked',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'points',
|
||||
name: 'Points',
|
||||
component: () => import('@/views/Points.vue'),
|
||||
meta: {
|
||||
title: '积分管理 - 积分商城管理系统',
|
||||
icon: 'Coin',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: 'transfers',
|
||||
name: 'Transfers',
|
||||
component: () => import('@/views/Transfers.vue'),
|
||||
meta: {
|
||||
title: '转账管理 - 炬融圈',
|
||||
icon: 'Money',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'beans',
|
||||
name: 'Beans',
|
||||
component: () => import('@/views/Beans.vue'),
|
||||
meta: {
|
||||
title: '融豆管理 - 炬融圈',
|
||||
icon: 'Promotion',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'daily-transfer-stats',
|
||||
name: 'DailyTransferStats',
|
||||
component: () => import('@/views/DailyTransferStats.vue'),
|
||||
meta: {
|
||||
title: '昨日转账统计 - 炬融圈',
|
||||
icon: 'DataAnalysis',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
name: 'Agents',
|
||||
component: () => import('@/views/Agents.vue'),
|
||||
meta: {
|
||||
title: '代理管理 - 炬融圈',
|
||||
icon: 'Avatar',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'Announcements',
|
||||
component: () => import('@/views/Announcements.vue'),
|
||||
meta: {
|
||||
title: '通知公告 - 炬融圈',
|
||||
icon: 'Bell',
|
||||
requiresAdmin: true
|
||||
}
|
||||
},
|
||||
// {
|
||||
// path: 'database-monitor',
|
||||
// name: 'DatabaseMonitor',
|
||||
// component: () => import('@/views/DatabaseMonitor.vue'),
|
||||
// meta: {
|
||||
// title: '数据库监控 - 炬融圈',
|
||||
// icon: 'Monitor',
|
||||
// requiresAdmin: true
|
||||
// }
|
||||
// },
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile.vue'),
|
||||
meta: {
|
||||
title: '个人资料 - 炬融圈',
|
||||
icon: 'UserFilled'
|
||||
}
|
||||
}
|
||||
// {
|
||||
// path: 'settings',
|
||||
// name: 'Settings',
|
||||
// component: () => import('@/views/Settings.vue'),
|
||||
// meta: {
|
||||
// title: '系统设置 - 炬融圈',
|
||||
// icon: 'Setting',
|
||||
// requiresAdmin: true
|
||||
// }
|
||||
// }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/404.vue'),
|
||||
meta: {
|
||||
title: '页面未找到 - 炬融圈'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/admin/'),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title
|
||||
}
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth !== false) {
|
||||
if (!userStore.isAuthenticated) {
|
||||
// 只在没有token或用户信息时才尝试从本地存储恢复登录状态
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const userStr = localStorage.getItem('admin_user')
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr)
|
||||
userStore.token = token
|
||||
userStore.user = user
|
||||
} catch (error) {
|
||||
console.error('解析用户信息失败:', error)
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
}
|
||||
}
|
||||
|
||||
if (!userStore.isAuthenticated) {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查管理员权限
|
||||
if (to.meta.requiresAdmin && !userStore.isAdmin) {
|
||||
ElMessage.error('您没有权限访问此页面')
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已登录用户访问登录页,重定向到仪表盘
|
||||
if (to.name === 'Login' && userStore.isAuthenticated) {
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
188
src/stores/user.js
Normal file
188
src/stores/user.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/utils/api'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('admin_token') || null,
|
||||
loading: false,
|
||||
statusCheckInterval: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token && !!state.user,
|
||||
isAdmin: (state) => state.user?.role === 'admin'
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 登录
|
||||
async login(credentials) {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await api.auth.login(credentials)
|
||||
const { token, user } = response.data
|
||||
|
||||
this.token = token
|
||||
this.user = user
|
||||
|
||||
// 存储到本地存储
|
||||
localStorage.setItem('admin_token', token)
|
||||
localStorage.setItem('admin_user', JSON.stringify(user))
|
||||
|
||||
this.startStatusCheck() // 登录成功后开始状态检查
|
||||
ElMessage.success(`欢迎回来,${user.username}!`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '登录失败'
|
||||
ElMessage.error(message)
|
||||
return { success: false, message }
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 登出
|
||||
async logout() {
|
||||
try {
|
||||
this.stopStatusCheck() // 登出时停止状态检查
|
||||
|
||||
// 清除状态
|
||||
this.user = null
|
||||
this.token = null
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
|
||||
ElMessage.success('退出成功,期待您的再次光临!')
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 检查认证状态(仅在需要时验证token有效性)
|
||||
async checkAuth() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const userStr = localStorage.getItem('admin_user')
|
||||
|
||||
if (!token || !userStr) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 先从本地存储恢复状态
|
||||
const user = JSON.parse(userStr)
|
||||
this.token = token
|
||||
this.user = user
|
||||
|
||||
// 可选:验证token是否仍然有效(仅在必要时调用)
|
||||
// 这里不主动验证,让具体的API调用时自然验证
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('认证检查失败:', error)
|
||||
// 清除无效的本地存储数据
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// 更新个人信息
|
||||
async updateProfile(profileData) {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await api.users.updateUser(this.user.id, profileData)
|
||||
|
||||
this.user = { ...this.user, ...response.data }
|
||||
localStorage.setItem('admin_user', JSON.stringify(this.user))
|
||||
|
||||
ElMessage.success('个人信息更新成功')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '更新失败'
|
||||
ElMessage.error(message)
|
||||
return { success: false, message }
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 修改密码
|
||||
async changePassword(passwordData) {
|
||||
this.loading = true
|
||||
try {
|
||||
await api.auth.changePassword(passwordData)
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
|
||||
// 修改密码后需要重新登录
|
||||
setTimeout(() => {
|
||||
this.logout()
|
||||
}, 1500)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '密码修改失败'
|
||||
ElMessage.error(message)
|
||||
return { success: false, message }
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 获取用户详情
|
||||
async getUserDetails(userId) {
|
||||
try {
|
||||
const response = await api.users.getUserById(userId)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '获取用户详情失败'
|
||||
ElMessage.error(message)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 获取当前用户信息
|
||||
async fetchUserInfo() {
|
||||
try {
|
||||
const response = await api.auth.getCurrentUser()
|
||||
this.user = response.data.user
|
||||
localStorage.setItem('admin_user', JSON.stringify(this.user))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || '获取用户信息失败'
|
||||
console.error('获取用户信息失败:', error)
|
||||
return { success: false, message }
|
||||
}
|
||||
},
|
||||
|
||||
// 开始状态检查
|
||||
startStatusCheck() {
|
||||
// 如果已经有定时器在运行,先清除
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval)
|
||||
}
|
||||
|
||||
// 每5分钟检查一次用户状态
|
||||
this.statusCheckInterval = setInterval(async () => {
|
||||
if (this.isAuthenticated) {
|
||||
try {
|
||||
await api.auth.getCurrentUser()
|
||||
} catch (error) {
|
||||
// 如果请求失败,说明token可能已失效或用户被拉黑
|
||||
// api拦截器会自动处理这些情况
|
||||
console.log('用户状态检查失败,可能已被拉黑或token失效')
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000) // 5分钟
|
||||
},
|
||||
|
||||
// 停止状态检查
|
||||
stopStatusCheck() {
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval)
|
||||
this.statusCheckInterval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
298
src/style.css
Normal file
298
src/style.css
Normal file
@@ -0,0 +1,298 @@
|
||||
/* 样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
a {
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
|
||||
/* 工具类 */
|
||||
.text-left { text-align: left !important; }
|
||||
.text-center { text-align: center !important; }
|
||||
.text-right { text-align: right !important; }
|
||||
.text-justify { text-align: justify !important; }
|
||||
|
||||
.float-left { float: left !important; }
|
||||
.float-right { float: right !important; }
|
||||
.clearfix::after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.d-none { display: none !important; }
|
||||
.d-inline { display: inline !important; }
|
||||
.d-inline-block { display: inline-block !important; }
|
||||
.d-block { display: block !important; }
|
||||
.d-flex { display: flex !important; }
|
||||
.d-inline-flex { display: inline-flex !important; }
|
||||
|
||||
.flex-row { flex-direction: row !important; }
|
||||
.flex-column { flex-direction: column !important; }
|
||||
.flex-wrap { flex-wrap: wrap !important; }
|
||||
.flex-nowrap { flex-wrap: nowrap !important; }
|
||||
|
||||
.justify-content-start { justify-content: flex-start !important; }
|
||||
.justify-content-end { justify-content: flex-end !important; }
|
||||
.justify-content-center { justify-content: center !important; }
|
||||
.justify-content-between { justify-content: space-between !important; }
|
||||
.justify-content-around { justify-content: space-around !important; }
|
||||
|
||||
.align-items-start { align-items: flex-start !important; }
|
||||
.align-items-end { align-items: flex-end !important; }
|
||||
.align-items-center { align-items: center !important; }
|
||||
.align-items-baseline { align-items: baseline !important; }
|
||||
.align-items-stretch { align-items: stretch !important; }
|
||||
|
||||
.flex-fill { flex: 1 1 auto !important; }
|
||||
.flex-grow-0 { flex-grow: 0 !important; }
|
||||
.flex-grow-1 { flex-grow: 1 !important; }
|
||||
.flex-shrink-0 { flex-shrink: 0 !important; }
|
||||
.flex-shrink-1 { flex-shrink: 1 !important; }
|
||||
|
||||
/* 间距工具类 */
|
||||
.m-0 { margin: 0 !important; }
|
||||
.m-1 { margin: 0.25rem !important; }
|
||||
.m-2 { margin: 0.5rem !important; }
|
||||
.m-3 { margin: 1rem !important; }
|
||||
.m-4 { margin: 1.5rem !important; }
|
||||
.m-5 { margin: 3rem !important; }
|
||||
|
||||
.mt-0 { margin-top: 0 !important; }
|
||||
.mt-1 { margin-top: 0.25rem !important; }
|
||||
.mt-2 { margin-top: 0.5rem !important; }
|
||||
.mt-3 { margin-top: 1rem !important; }
|
||||
.mt-4 { margin-top: 1.5rem !important; }
|
||||
.mt-5 { margin-top: 3rem !important; }
|
||||
|
||||
.mb-0 { margin-bottom: 0 !important; }
|
||||
.mb-1 { margin-bottom: 0.25rem !important; }
|
||||
.mb-2 { margin-bottom: 0.5rem !important; }
|
||||
.mb-3 { margin-bottom: 1rem !important; }
|
||||
.mb-4 { margin-bottom: 1.5rem !important; }
|
||||
.mb-5 { margin-bottom: 3rem !important; }
|
||||
|
||||
.ml-0 { margin-left: 0 !important; }
|
||||
.ml-1 { margin-left: 0.25rem !important; }
|
||||
.ml-2 { margin-left: 0.5rem !important; }
|
||||
.ml-3 { margin-left: 1rem !important; }
|
||||
.ml-4 { margin-left: 1.5rem !important; }
|
||||
.ml-5 { margin-left: 3rem !important; }
|
||||
|
||||
.mr-0 { margin-right: 0 !important; }
|
||||
.mr-1 { margin-right: 0.25rem !important; }
|
||||
.mr-2 { margin-right: 0.5rem !important; }
|
||||
.mr-3 { margin-right: 1rem !important; }
|
||||
.mr-4 { margin-right: 1.5rem !important; }
|
||||
.mr-5 { margin-right: 3rem !important; }
|
||||
|
||||
.p-0 { padding: 0 !important; }
|
||||
.p-1 { padding: 0.25rem !important; }
|
||||
.p-2 { padding: 0.5rem !important; }
|
||||
.p-3 { padding: 1rem !important; }
|
||||
.p-4 { padding: 1.5rem !important; }
|
||||
.p-5 { padding: 3rem !important; }
|
||||
|
||||
.pt-0 { padding-top: 0 !important; }
|
||||
.pt-1 { padding-top: 0.25rem !important; }
|
||||
.pt-2 { padding-top: 0.5rem !important; }
|
||||
.pt-3 { padding-top: 1rem !important; }
|
||||
.pt-4 { padding-top: 1.5rem !important; }
|
||||
.pt-5 { padding-top: 3rem !important; }
|
||||
|
||||
.pb-0 { padding-bottom: 0 !important; }
|
||||
.pb-1 { padding-bottom: 0.25rem !important; }
|
||||
.pb-2 { padding-bottom: 0.5rem !important; }
|
||||
.pb-3 { padding-bottom: 1rem !important; }
|
||||
.pb-4 { padding-bottom: 1.5rem !important; }
|
||||
.pb-5 { padding-bottom: 3rem !important; }
|
||||
|
||||
.pl-0 { padding-left: 0 !important; }
|
||||
.pl-1 { padding-left: 0.25rem !important; }
|
||||
.pl-2 { padding-left: 0.5rem !important; }
|
||||
.pl-3 { padding-left: 1rem !important; }
|
||||
.pl-4 { padding-left: 1.5rem !important; }
|
||||
.pl-5 { padding-left: 3rem !important; }
|
||||
|
||||
.pr-0 { padding-right: 0 !important; }
|
||||
.pr-1 { padding-right: 0.25rem !important; }
|
||||
.pr-2 { padding-right: 0.5rem !important; }
|
||||
.pr-3 { padding-right: 1rem !important; }
|
||||
.pr-4 { padding-right: 1.5rem !important; }
|
||||
.pr-5 { padding-right: 3rem !important; }
|
||||
|
||||
/* 宽度高度 */
|
||||
.w-25 { width: 25% !important; }
|
||||
.w-50 { width: 50% !important; }
|
||||
.w-75 { width: 75% !important; }
|
||||
.w-100 { width: 100% !important; }
|
||||
.w-auto { width: auto !important; }
|
||||
|
||||
.h-25 { height: 25% !important; }
|
||||
.h-50 { height: 50% !important; }
|
||||
.h-75 { height: 75% !important; }
|
||||
.h-100 { height: 100% !important; }
|
||||
.h-auto { height: auto !important; }
|
||||
|
||||
/* 颜色 */
|
||||
.text-primary { color: #409eff !important; }
|
||||
.text-success { color: #67c23a !important; }
|
||||
.text-warning { color: #e6a23c !important; }
|
||||
.text-danger { color: #f56c6c !important; }
|
||||
.text-info { color: #909399 !important; }
|
||||
.text-light { color: #f8f9fa !important; }
|
||||
.text-dark { color: #343a40 !important; }
|
||||
.text-muted { color: #6c757d !important; }
|
||||
.text-white { color: #fff !important; }
|
||||
|
||||
.bg-primary { background-color: #409eff !important; }
|
||||
.bg-success { background-color: #67c23a !important; }
|
||||
.bg-warning { background-color: #e6a23c !important; }
|
||||
.bg-danger { background-color: #f56c6c !important; }
|
||||
.bg-info { background-color: #909399 !important; }
|
||||
.bg-light { background-color: #f8f9fa !important; }
|
||||
.bg-dark { background-color: #343a40 !important; }
|
||||
.bg-white { background-color: #fff !important; }
|
||||
|
||||
/* 边框 */
|
||||
.border { border: 1px solid #dee2e6 !important; }
|
||||
.border-top { border-top: 1px solid #dee2e6 !important; }
|
||||
.border-right { border-right: 1px solid #dee2e6 !important; }
|
||||
.border-bottom { border-bottom: 1px solid #dee2e6 !important; }
|
||||
.border-left { border-left: 1px solid #dee2e6 !important; }
|
||||
.border-0 { border: 0 !important; }
|
||||
|
||||
.rounded { border-radius: 0.25rem !important; }
|
||||
.rounded-top { border-top-left-radius: 0.25rem !important; border-top-right-radius: 0.25rem !important; }
|
||||
.rounded-right { border-top-right-radius: 0.25rem !important; border-bottom-right-radius: 0.25rem !important; }
|
||||
.rounded-bottom { border-bottom-right-radius: 0.25rem !important; border-bottom-left-radius: 0.25rem !important; }
|
||||
.rounded-left { border-top-left-radius: 0.25rem !important; border-bottom-left-radius: 0.25rem !important; }
|
||||
.rounded-circle { border-radius: 50% !important; }
|
||||
.rounded-0 { border-radius: 0 !important; }
|
||||
|
||||
/* 阴影 */
|
||||
.shadow-sm { box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important; }
|
||||
.shadow { box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; }
|
||||
.shadow-lg { box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important; }
|
||||
.shadow-none { box-shadow: none !important; }
|
||||
|
||||
/* 过渡动画 */
|
||||
.transition { transition: all 0.3s ease !important; }
|
||||
.transition-fast { transition: all 0.15s ease !important; }
|
||||
.transition-slow { transition: all 0.6s ease !important; }
|
||||
|
||||
/* 鼠标样式 */
|
||||
.cursor-pointer { cursor: pointer !important; }
|
||||
.cursor-default { cursor: default !important; }
|
||||
.cursor-not-allowed { cursor: not-allowed !important; }
|
||||
|
||||
/* 溢出处理 */
|
||||
.overflow-auto { overflow: auto !important; }
|
||||
.overflow-hidden { overflow: hidden !important; }
|
||||
.overflow-visible { overflow: visible !important; }
|
||||
.overflow-scroll { overflow: scroll !important; }
|
||||
|
||||
.text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-break {
|
||||
word-wrap: break-word !important;
|
||||
word-break: break-word !important;
|
||||
}
|
||||
|
||||
/* 响应式隐藏 */
|
||||
@media (max-width: 576px) {
|
||||
.d-sm-none { display: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.d-md-none { display: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.d-lg-none { display: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.d-xl-none { display: none !important; }
|
||||
}
|
||||
|
||||
/* 自定义动画 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slideInLeft {
|
||||
animation: slideInLeft 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slideInRight {
|
||||
animation: slideInRight 0.5s ease-in-out;
|
||||
}
|
||||
331
src/utils/api.js
Normal file
331
src/utils/api.js
Normal file
@@ -0,0 +1,331 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import NProgress from 'nprogress'
|
||||
|
||||
// 创建axios实例
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
let loadingInstance = null
|
||||
let requestCount = 0
|
||||
let isLoggingOut = false // 防止重复登出
|
||||
|
||||
// 显示加载
|
||||
const showLoading = () => {
|
||||
if (requestCount === 0) {
|
||||
loadingInstance = ElLoading.service({
|
||||
text: '加载中...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
}
|
||||
requestCount++
|
||||
}
|
||||
|
||||
// 隐藏加载
|
||||
const hideLoading = () => {
|
||||
requestCount--
|
||||
if (requestCount <= 0) {
|
||||
requestCount = 0
|
||||
if (loadingInstance) {
|
||||
loadingInstance.close()
|
||||
loadingInstance = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
// 开始进度条
|
||||
NProgress.start()
|
||||
|
||||
// 显示加载动画(除了某些不需要的请求)
|
||||
if (!config.hideLoading) {
|
||||
showLoading()
|
||||
}
|
||||
|
||||
// 添加认证token
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
hideLoading()
|
||||
NProgress.done()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
hideLoading()
|
||||
NProgress.done()
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
hideLoading()
|
||||
NProgress.done()
|
||||
|
||||
const { response } = error
|
||||
|
||||
if (response) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
// 防止重复处理401错误
|
||||
if (!isLoggingOut) {
|
||||
isLoggingOut = true
|
||||
|
||||
// 只在非登录页面显示错误消息
|
||||
if (window.location.pathname !== '/admin/login') {
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
|
||||
// 立即跳转到登录页,减少延迟
|
||||
setTimeout(() => {
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
// 重置标志
|
||||
setTimeout(() => {
|
||||
isLoggingOut = false
|
||||
}, 1000)
|
||||
}, 500)
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
// 检查是否是用户被拉黑
|
||||
if (response.data.code === 'USER_BLACKLISTED') {
|
||||
// 防止重复处理拉黑错误
|
||||
if (!isLoggingOut) {
|
||||
isLoggingOut = true
|
||||
|
||||
ElMessage.error(response.data.message || '账户已被拉黑,请联系管理员')
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
// 重置标志
|
||||
setTimeout(() => {
|
||||
isLoggingOut = false
|
||||
}, 1000)
|
||||
}, 500)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('没有权限访问此资源')
|
||||
}
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('请求的资源不存在')
|
||||
break
|
||||
case 422:
|
||||
ElMessage.error(response.data.message || '请求参数错误')
|
||||
break
|
||||
case 429:
|
||||
ElMessage.error('请求过于频繁,请稍后再试')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('服务器内部错误')
|
||||
break
|
||||
default:
|
||||
ElMessage.error(response.data.error.message || '请求失败')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('网络错误,请检查网络连接')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// API接口定义
|
||||
const api = {
|
||||
// 认证相关
|
||||
auth: {
|
||||
login: (data) => request.post('/auth/login', data),
|
||||
register: (data) => request.post('/auth/register', data),
|
||||
getCurrentUser: () => request.get('/auth/me'),
|
||||
changePassword: (data) => request.put('/auth/change-password', data)
|
||||
},
|
||||
|
||||
// 用户管理
|
||||
users: {
|
||||
getUsers: (params) => request.get('/users', { params }),
|
||||
getUserById: (id) => request.get(`/users/${id}`),
|
||||
createUser: (data) => request.post('/users', data),
|
||||
updateUser: (id, data) => request.put(`/users/${id}`, data),
|
||||
deleteUser: (id) => request.delete(`/users/${id}`),
|
||||
getUserStats: () => request.get('/users/stats'),
|
||||
getUserGrowthTrend: (params) => request.get('/users/growth-trend', { params }),
|
||||
getDailyRevenue: (params) => request.get('/users/daily-revenue', { params }),
|
||||
getAgentOptions: (params) => request.get('/admin/agents', { params })
|
||||
},
|
||||
|
||||
// 积分管理
|
||||
points: {
|
||||
getStats: () => request.get('/points/stats'),
|
||||
getHistory: (params) => request.get('/points/history', { params }),
|
||||
adjustPoints: (data) => request.post('/points/adjust', data)
|
||||
},
|
||||
|
||||
beans: {
|
||||
getHistory: (params) => request.get('/transfers/history', { params }),
|
||||
},
|
||||
|
||||
// 文件上传
|
||||
upload: {
|
||||
uploadImage: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
return request.post('/upload/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
uploadFile: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post('/upload/file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// 系统统计
|
||||
dashboard: {
|
||||
getStats: () => request.get('/dashboard/stats', { hideLoading: true }),
|
||||
getChartData: (type) => request.get(`/dashboard/charts/${type}`, { hideLoading: true })
|
||||
},
|
||||
|
||||
// 激活码管理
|
||||
registrationCodes: {
|
||||
getRegistrationCodes: (params) => request.get('/users/registration-codes', { params }),
|
||||
createRegistrationCode: (data) => request.post('/users/registration-codes', data),
|
||||
batchCreateRegistrationCodes: (data) => request.post('/users/registration-codes/batch', data),
|
||||
deleteRegistrationCode: (id) => request.delete(`/users/registration-codes/${id}`)
|
||||
},
|
||||
|
||||
// 转账管理
|
||||
transfers: {
|
||||
getTransfers: (params) => request.get('/transfers', { params }),
|
||||
getTransferById: (id) => request.get(`/transfers/${id}`),
|
||||
confirmTransfer: (id) => request.post('/transfers/confirm', { transfer_id: id }),
|
||||
rejectTransfer: (id) => request.post('/transfers/reject', { transfer_id: id }),
|
||||
confirmReceived: (id) => request.post('/transfers/confirm-received', { transfer_id: id }),
|
||||
confirmNotReceived: (id) => request.post('/transfers/confirm-not-received', { transfer_id: id }),
|
||||
getTransferStats: () => request.get('/transfers/stats'),
|
||||
getTransferTrend: (params) => request.get('/transfers/trend', { params }),
|
||||
getPublicAccount: () => request.get('/transfers/public-account'),
|
||||
getUserTransfers: (userId, params) => request.get(`/transfers/user/${userId}`, { params }),
|
||||
getPendingTransfers: () => request.get('/transfers/pending'),
|
||||
getAccountInfo: (userId) => request.get(`/transfers/account/${userId}`),
|
||||
createAdminTransfer: (fromUserId, data) => {
|
||||
return request.post('/transfers/admin/create', {
|
||||
from_user_id: fromUserId,
|
||||
...data
|
||||
})
|
||||
},
|
||||
removeBadDebt: (transferId, reason) => request.post(`/transfers/remove-bad-debt/${transferId}`, { reason }),
|
||||
// 强制变更转账状态
|
||||
forceChangeStatus: (transferId, data) => request.post(`/transfers/force-change-status/${transferId}`, data),
|
||||
|
||||
// 数据库监控
|
||||
getDatabaseStatus: () => request.get('/transfers/admin/database/status'),
|
||||
getDatabaseReport: () => request.get('/transfers/admin/database/report'),
|
||||
|
||||
// 待处理匹配订单相关
|
||||
getPendingAllocations: (params) => request.get('/transfers/pending-allocations', { params }),
|
||||
getPendingAllocationStats: () => request.get('/transfers/pending-allocations/stats'),
|
||||
|
||||
// 昨日用户转账统计
|
||||
getDailyStats: () => request.get('/transfers/daily-stats')
|
||||
},
|
||||
|
||||
// 系统设置
|
||||
system: {
|
||||
getSettings: () => request.get('/system/settings'),
|
||||
updateSettings: (category, data) => {
|
||||
// 构造符合后端期望的数据格式
|
||||
const payload = { [category]: data }
|
||||
return request.put('/system/settings', payload)
|
||||
},
|
||||
getSystemInfo: () => request.get('/system/info'),
|
||||
|
||||
},
|
||||
|
||||
// 匹配管理
|
||||
matching: {
|
||||
getUnreasonableMatches: (params) => request.get('/admin/matching/unreasonable-matches', { params }),
|
||||
getMatchingStats: () => request.get('/admin/matching/matching-stats'),
|
||||
fixUnreasonableMatch: (allocationId, data) => request.post(`/admin/matching/fix-unreasonable-match/${allocationId}`, data),
|
||||
fixAllUnreasonable: () => request.post('/admin/matching/fix-all-unreasonable'),
|
||||
confirmAllocation: (allocationId) => request.post(`/admin/matching/confirm-allocation/${allocationId}`),
|
||||
cancelAllocation: (allocationId) => request.post(`/admin/matching/cancel-allocation/${allocationId}`)
|
||||
},
|
||||
|
||||
// 商品管理
|
||||
products: {
|
||||
getProducts: (params) => request.get('/products', { params }),
|
||||
getProductById: (id) => request.get(`/products/${id}`),
|
||||
createProduct: (data) => request.post('/products', data),
|
||||
updateProduct: (id, data) => request.put(`/products/${id}`, data),
|
||||
deleteProduct: (id) => request.delete(`/products/${id}`),
|
||||
getCategories: () => request.get('/products/categories'),
|
||||
|
||||
// 商品属性
|
||||
getAttributes: (productId) => request.get(`/products/${productId}/attributes`),
|
||||
createAttribute: (productId, data) => request.post(`/products/${productId}/attributes`, data),
|
||||
updateAttribute: (productId, attrId, data) => request.put(`/products/${productId}/attributes/${attrId}`, data),
|
||||
deleteAttribute: (productId, attrId) => request.delete(`/products/${productId}/attributes/${attrId}`)
|
||||
},
|
||||
|
||||
// 新的规格管理系统(笛卡尔积)
|
||||
specifications: {
|
||||
// 规格名称管理
|
||||
getSpecNames: () => request.get('/specifications/names'),
|
||||
createSpecName: (data) => request.post('/specifications/names', data),
|
||||
updateSpecName: (id, data) => request.put(`/specifications/names/${id}`, data),
|
||||
deleteSpecName: (id) => request.delete(`/specifications/names/${id}`),
|
||||
|
||||
// 规格值管理
|
||||
getSpecValues: (specNameId) => request.get('/specifications/values', { params: { spec_name_id: specNameId } }),
|
||||
createSpecValue: (data) => request.post('/specifications/values', data),
|
||||
updateSpecValue: (id, data) => request.put(`/specifications/values/${id}`, data),
|
||||
deleteSpecValue: (id) => request.delete(`/specifications/values/${id}`),
|
||||
|
||||
// 规格组合管理
|
||||
getCombinations: (productId) => request.get(`/specifications/combinations/${productId}`),
|
||||
generateCombinations: (data) => request.post('/specifications/generate-combinations', data),
|
||||
updateCombination: (id, data) => request.put(`/specifications/combinations/${id}`, data),
|
||||
deleteCombination: (id) => request.delete(`/specifications/combinations/${id}`)
|
||||
},
|
||||
|
||||
// 为了向后兼容,添加直接的 get、post 等方法
|
||||
get: (url, config) => request.get(url, config),
|
||||
post: (url, data, config) => request.post(url, data, config),
|
||||
put: (url, data, config) => request.put(url, data, config),
|
||||
delete: (url, config) => request.delete(url, config)
|
||||
}
|
||||
|
||||
export default api
|
||||
68
src/utils/config.js
Normal file
68
src/utils/config.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// 环境配置
|
||||
const config = {
|
||||
development: {
|
||||
baseURL: 'https://minio.zrbjr.com',
|
||||
uploadURL: 'http://localhost:3001/api/upload'
|
||||
},
|
||||
production: {
|
||||
baseURL: 'https://minio.zrbjr.com',
|
||||
uploadURL: `${window.location.origin}/api/upload`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前环境
|
||||
const env = import.meta.env.MODE || 'development'
|
||||
|
||||
// 导出当前环境的配置
|
||||
export default config[env]
|
||||
|
||||
// 导出具体配置项
|
||||
export const { baseURL, uploadURL } = config[env]
|
||||
|
||||
/**
|
||||
* 获取完整的图片URL
|
||||
* @param {string} imagePath - 图片路径
|
||||
* @returns {string} 完整的图片URL
|
||||
*/
|
||||
export const getImageUrl = (imagePath) => {
|
||||
// console.log('getImageUrl called with:', imagePath)
|
||||
if (!imagePath) return ''
|
||||
if (imagePath.startsWith('http')) return imagePath
|
||||
// console.log(imagePath,'imagePath');
|
||||
|
||||
// 如果图片路径以/uploads开头,直接返回原路径
|
||||
if (imagePath.startsWith('/uploads')) {
|
||||
const cleanBaseURL = baseURL.replace(/\/$/, '')
|
||||
// console.log('Image starts with /uploads, returning original path:', imagePath)
|
||||
return `${imagePath}`
|
||||
}
|
||||
|
||||
// 在开发环境下,也需要根据路径前缀处理
|
||||
if (env === 'development') {
|
||||
const cleanBaseURL = baseURL.replace(/\/$/, '')
|
||||
const cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
|
||||
const fullUrl = `${cleanBaseURL}${cleanImagePath}`
|
||||
// console.log('Development environment, returning:', fullUrl)
|
||||
return fullUrl
|
||||
}
|
||||
|
||||
// 生产环境下使用完整URL
|
||||
const cleanBaseURL = baseURL.replace(/\/$/, '')
|
||||
const cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
|
||||
const fullUrl = `${cleanBaseURL}${cleanImagePath}`
|
||||
|
||||
return fullUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传配置
|
||||
* @returns {object} 上传配置对象
|
||||
*/
|
||||
export const getUploadConfig = () => {
|
||||
return {
|
||||
action: uploadURL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/views/404.vue
Normal file
97
src/views/404.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<div class="error-code">404</div>
|
||||
<div class="error-message">页面未找到</div>
|
||||
<div class="error-description">
|
||||
抱歉,您访问的页面不存在或已被删除
|
||||
</div>
|
||||
<div class="error-actions">
|
||||
<el-button type="primary" @click="goHome">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回上页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { HomeFilled, ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
margin-bottom: 40px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-actions .el-button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-code {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1288
src/views/Agents.vue
Normal file
1288
src/views/Agents.vue
Normal file
File diff suppressed because it is too large
Load Diff
684
src/views/Announcements.vue
Normal file
684
src/views/Announcements.vue
Normal file
@@ -0,0 +1,684 @@
|
||||
<template>
|
||||
<div class="announcements-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">通知公告管理</h1>
|
||||
<p class="page-subtitle">管理系统中的所有通知公告</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card class="search-card" shadow="never">
|
||||
<el-row :gutter="20" class="search-row">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索公告标题或内容"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="选择状态"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="已发布" value="published" />
|
||||
<el-option label="已归档" value="archived" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<el-select
|
||||
v-model="searchForm.type"
|
||||
placeholder="选择类型"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="系统通知" value="system" />
|
||||
<el-option label="维护公告" value="maintenance" />
|
||||
<el-option label="活动推广" value="promotion" />
|
||||
<el-option label="重要警告" value="warning" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" class="button-group">
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 公告列表 -->
|
||||
<el-card class="table-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">公告列表 ({{ pagination.total }})</span>
|
||||
<el-button type="primary" @click="showCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建公告
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="announcements"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||
|
||||
<el-table-column prop="title" label="标题" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="title-cell">
|
||||
<el-tag v-if="row.is_pinned" type="danger" size="small" class="pin-tag">
|
||||
置顶
|
||||
</el-tag>
|
||||
<span class="title-text">{{ row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTypeTagType(row.type)" size="small">
|
||||
{{ getTypeLabel(row.type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="priority" label="优先级" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getPriorityTagType(row.priority)" size="small">
|
||||
{{ getPriorityLabel(row.priority) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.status)" size="small">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="creator_name" label="创建者" width="120" />
|
||||
|
||||
<el-table-column prop="publish_time" label="发布时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.publish_time ? formatDateTime(row.publish_time) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="showEditDialog(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'draft'"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="publishAnnouncement(row)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="deleteAnnouncement(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 创建/编辑公告对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑公告' : '新建公告'"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="公告标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入公告标题" maxlength="255" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="公告内容" prop="content">
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入公告内容"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="公告类型" prop="type">
|
||||
<el-select v-model="form.type" placeholder="选择公告类型" style="width: 100%">
|
||||
<el-option label="系统通知" value="system" />
|
||||
<el-option label="维护公告" value="maintenance" />
|
||||
<el-option label="活动推广" value="promotion" />
|
||||
<el-option label="重要警告" value="warning" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="优先级" prop="priority">
|
||||
<el-select v-model="form.priority" placeholder="选择优先级" style="width: 100%">
|
||||
<el-option label="低" value="low" />
|
||||
<el-option label="中" value="medium" />
|
||||
<el-option label="高" value="high" />
|
||||
<el-option label="紧急" value="urgent" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" placeholder="选择状态" style="width: 100%">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="发布" value="published" />
|
||||
<el-option label="归档" value="archived" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="是否置顶">
|
||||
<el-switch v-model="form.is_pinned" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="发布时间">
|
||||
<el-date-picker
|
||||
v-model="form.publish_time"
|
||||
type="datetime"
|
||||
placeholder="选择发布时间"
|
||||
style="width: 100%"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="过期时间">
|
||||
<el-date-picker
|
||||
v-model="form.expire_time"
|
||||
type="datetime"
|
||||
placeholder="选择过期时间"
|
||||
style="width: 100%"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
{{ isEdit ? '更新' : '创建' }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import api from '@/utils/api'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 公告列表
|
||||
const announcements = ref([])
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
type: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: null,
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'system',
|
||||
priority: 'medium',
|
||||
status: 'draft',
|
||||
is_pinned: false,
|
||||
publish_time: '',
|
||||
expire_time: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入公告标题', trigger: 'blur' },
|
||||
{ min: 1, max: 255, message: '标题长度在 1 到 255 个字符', trigger: 'blur' }
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入公告内容', trigger: 'blur' },
|
||||
{ min: 1, max: 2000, message: '内容长度在 1 到 2000 个字符', trigger: 'blur' }
|
||||
],
|
||||
type: [
|
||||
{ required: true, message: '请选择公告类型', trigger: 'change' }
|
||||
],
|
||||
priority: [
|
||||
{ required: true, message: '请选择优先级', trigger: 'change' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const totalPages = computed(() => Math.ceil(pagination.total / pagination.limit))
|
||||
|
||||
// 方法
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
...searchForm
|
||||
}
|
||||
|
||||
const response = await api.get('/announcements', { params })
|
||||
if (response.data.success) {
|
||||
announcements.value = response.data.data.announcements
|
||||
pagination.total = response.data.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取公告列表失败:', error)
|
||||
ElMessage.error('获取公告列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchAnnouncements()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, {
|
||||
keyword: '',
|
||||
status: '',
|
||||
type: ''
|
||||
})
|
||||
pagination.page = 1
|
||||
fetchAnnouncements()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.limit = size
|
||||
pagination.page = 1
|
||||
fetchAnnouncements()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
pagination.page = page
|
||||
fetchAnnouncements()
|
||||
}
|
||||
|
||||
const handleSortChange = ({ prop, order }) => {
|
||||
// 实现排序逻辑
|
||||
fetchAnnouncements()
|
||||
}
|
||||
|
||||
const showCreateDialog = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const showEditDialog = (row) => {
|
||||
isEdit.value = true
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
is_pinned: row.is_pinned,
|
||||
publish_time: row.publish_time,
|
||||
expire_time: row.expire_time
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'system',
|
||||
priority: 'medium',
|
||||
status: 'draft',
|
||||
is_pinned: false,
|
||||
publish_time: '',
|
||||
expire_time: ''
|
||||
})
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
const data = { ...form }
|
||||
delete data.id
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await api.put(`/announcements/${form.id}`, data)
|
||||
} else {
|
||||
response = await api.post('/announcements', data)
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
ElMessage.success(isEdit.value ? '公告更新成功' : '公告创建成功')
|
||||
dialogVisible.value = false
|
||||
fetchAnnouncements()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
ElMessage.error(isEdit.value ? '公告更新失败' : '公告创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const publishAnnouncement = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要发布这个公告吗?', '确认发布', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const response = await api.put(`/announcements/${row.id}`, {
|
||||
status: 'published',
|
||||
publish_time: new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
ElMessage.success('公告发布成功')
|
||||
fetchAnnouncements()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('发布公告失败:', error)
|
||||
ElMessage.error('发布公告失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAnnouncement = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个公告吗?删除后无法恢复。', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const response = await api.delete(`/announcements/${row.id}`)
|
||||
if (response.data.success) {
|
||||
ElMessage.success('公告删除成功')
|
||||
fetchAnnouncements()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除公告失败:', error)
|
||||
ElMessage.error('删除公告失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助方法
|
||||
const getTypeLabel = (type) => {
|
||||
const labels = {
|
||||
system: '系统通知',
|
||||
maintenance: '维护公告',
|
||||
promotion: '活动推广',
|
||||
warning: '重要警告'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const getTypeTagType = (type) => {
|
||||
const types = {
|
||||
system: 'info',
|
||||
maintenance: 'warning',
|
||||
promotion: 'success',
|
||||
warning: 'danger'
|
||||
}
|
||||
return types[type] || 'info'
|
||||
}
|
||||
|
||||
const getPriorityLabel = (priority) => {
|
||||
const labels = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
urgent: '紧急'
|
||||
}
|
||||
return labels[priority] || priority
|
||||
}
|
||||
|
||||
const getPriorityTagType = (priority) => {
|
||||
const types = {
|
||||
low: 'info',
|
||||
medium: '',
|
||||
high: 'warning',
|
||||
urgent: 'danger'
|
||||
}
|
||||
return types[priority] || ''
|
||||
}
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
const labels = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '已归档'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
const getStatusTagType = (status) => {
|
||||
const types = {
|
||||
draft: 'info',
|
||||
published: 'success',
|
||||
archived: 'warning'
|
||||
}
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime) => {
|
||||
if (!dateTime) return '-'
|
||||
return new Date(dateTime).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchAnnouncements()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcements-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pin-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.announcements-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.search-row .el-col {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
346
src/views/Beans.vue
Normal file
346
src/views/Beans.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<div class="beans-container">
|
||||
<div class="header">
|
||||
<h2>融豆管理</h2>
|
||||
</div>
|
||||
|
||||
<!-- 转账统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total_to_admin || 0 }}</div>
|
||||
<div class="stat-label">转给系统的融豆总数</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#409eff"><Coin /></el-icon>
|
||||
</el-card>
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total_to_agent || 0 }}</div>
|
||||
<div class="stat-label">转给代理的融豆总数</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#67c23a"><User /></el-icon>
|
||||
</el-card>
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total_to_agent_directly || 0 }}</div>
|
||||
<div class="stat-label">转给直营代理的融豆总数</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#e6a23c"><User /></el-icon>
|
||||
</el-card>
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total_to_directly_operated || 0 }}</div>
|
||||
<div class="stat-label">转给直营的融豆总数</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#f56c6c"><User /></el-icon>
|
||||
</el-card>
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total_get || 0 }}</div>
|
||||
<div class="stat-label">提现总数</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#cbcde1"><Coin /></el-icon>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="filters.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
@keyup.enter="loadBeansHistory"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="转账类型">
|
||||
<el-select v-model="filters.type" placeholder="全部类型" clearable style="display: inline-block; width: 150px;">
|
||||
<el-option label="全部类型" value="" selected />
|
||||
<el-option label="用户转系统" value="user_to_system" />
|
||||
<el-option label="用户转代理" value="user_to_agent" />
|
||||
<el-option label="用户转分销" value="user_to_operated" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadBeansHistory">搜索</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-table :data="beansHistory" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="转出用户">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div>{{ row.from_username }}</div>
|
||||
<div class="user-real-name">{{ row.from_real_name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="转入用户">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div>{{ row.to_username }}</div>
|
||||
<div class="user-real-name">{{ row.to_real_name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="transfer_type" label="转账类型">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTransferTypeColor(row.transfer_type)">{{ getTransferTypeText(row.transfer_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" label="金额" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-text">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadBeansHistory"
|
||||
@current-change="loadBeansHistory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Coin, TrendCharts, ShoppingCart, User } from '@element-plus/icons-vue'
|
||||
import api from '@/utils/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const beansHistory = ref([])
|
||||
const stats = ref({})
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
type: '', // 默认显示全部类型
|
||||
dateRange: null
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 加载融豆统计
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const {data} = await api.beans.getHistory()
|
||||
stats.value = data.data.stats
|
||||
console.log('总览数据:',stats.value)
|
||||
} catch (error) {
|
||||
console.error('加载融豆统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载融豆历史
|
||||
const loadBeansHistory = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
username: filters.username,
|
||||
transfer_type: filters.type
|
||||
}
|
||||
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = filters.dateRange[0]
|
||||
params.endDate = filters.dateRange[1]
|
||||
}
|
||||
|
||||
const {data} = await api.beans.getHistory(params)
|
||||
beansHistory.value = data.data.transfers || []
|
||||
pagination.total = data.data.pagination?.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载转账历史失败:', error)
|
||||
beansHistory.value = []
|
||||
ElMessage.error('加载转账历史失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
Object.assign(filters, {
|
||||
username: '',
|
||||
type: '',
|
||||
dateRange: null
|
||||
})
|
||||
pagination.page = 1
|
||||
loadBeansHistory()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 获取转账类型颜色
|
||||
const getTransferTypeColor = (type) => {
|
||||
const colors = {
|
||||
user_to_system: 'warning',
|
||||
user_to_agent: 'success',
|
||||
user_to_operated: 'primary',
|
||||
agent_to_operated: 'info',
|
||||
user_to_user: 'primary',
|
||||
user_to_regional: 'success'
|
||||
}
|
||||
return colors[type] || 'info'
|
||||
}
|
||||
|
||||
// 获取转账类型文本
|
||||
const getTransferTypeText = (type) => {
|
||||
const texts = {
|
||||
user_to_system: '用户转系统',
|
||||
user_to_agent: '用户转代理',
|
||||
user_to_operated: '用户转分销',
|
||||
agent_to_operated: '代理转分销',
|
||||
user_to_user: '用户分享',
|
||||
user_to_regional: '区域保护'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBeansHistory()
|
||||
loadStats()
|
||||
// 确保筛选器默认值正确显示
|
||||
nextTick(() => {
|
||||
// 强制更新筛选器显示
|
||||
if (filters.type === '') {
|
||||
filters.type = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.beans-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.filters {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-real-name {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.amount-text {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
525
src/views/DailyTransferStats.vue
Normal file
525
src/views/DailyTransferStats.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<div class="daily-transfer-stats-container">
|
||||
<el-card class="page-header">
|
||||
<h2>昨日转账统计</h2>
|
||||
<p>统计用户昨日转出金额、今日入账金额及平账差额</p>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计概览 -->
|
||||
<el-card class="stats-overview">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ statsData.totalUsers }}</div>
|
||||
<div class="stat-label">涉及用户数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">¥{{ statsData.totalYesterdayOut.toLocaleString() }}</div>
|
||||
<div class="stat-label">昨日总转出</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">¥{{ statsData.totalTodayIn.toLocaleString() }}</div>
|
||||
<div class="stat-label">今日总入账</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" :class="{ 'negative': statsData.totalBalanceNeeded > 0, 'positive': statsData.totalBalanceNeeded < 0 }">
|
||||
¥{{ Math.abs(statsData.totalBalanceNeeded).toLocaleString() }}
|
||||
</div>
|
||||
<div class="stat-label">{{ statsData.totalBalanceNeeded > 0 ? '总缺口' : '总盈余' }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<el-card class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户转账统计详情</span>
|
||||
<div class="date-info">
|
||||
<el-tag type="info" size="small">昨日: {{ dateInfo.yesterday }}</el-tag>
|
||||
<el-tag type="success" size="small">今日: {{ dateInfo.today }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="userList"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
:default-sort="{ prop: 'balance_needed', order: 'descending' }"
|
||||
>
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="real_name" label="真实姓名" width="120" />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
|
||||
<el-table-column label="用户余额" width="120" sortable prop="balance">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="amount-text"
|
||||
:class="{
|
||||
'negative': parseFloat(row.balance) < 0,
|
||||
'positive': parseFloat(row.balance) > 0,
|
||||
'zero': parseFloat(row.balance) === 0
|
||||
}"
|
||||
>
|
||||
¥{{ parseFloat(row.balance).toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
|
||||
<el-table-column label="昨日转出金额" width="140" sortable prop="yesterday_out_amount">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-text">¥{{ parseFloat(row.yesterday_out_amount).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="今日转账金额" width="140" sortable prop="today_in_amount">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-text positive">¥{{ parseFloat(row.confirmed_from_amount).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="今日入账金额" width="140" sortable prop="today_in_amount">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-text positive">¥{{ parseFloat(row.today_in_amount).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="平账差额" width="140" sortable prop="balance_needed">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="amount-text"
|
||||
:class="{
|
||||
'negative': parseFloat(row.balance_needed) > 0,
|
||||
'positive': parseFloat(row.balance_needed) < 0,
|
||||
'zero': parseFloat(row.balance_needed) === 0
|
||||
}"
|
||||
>
|
||||
{{ parseFloat(row.balance_needed) > 0 ? '还需' : parseFloat(row.balance_needed) < 0 ? '盈余' : '已平账' }}
|
||||
{{ parseFloat(row.balance_needed) !== 0 ? '¥' + Math.abs(parseFloat(row.balance_needed)).toLocaleString() : '' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="平账状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="getBalanceStatusType(parseFloat(row.balance_needed))"
|
||||
size="small"
|
||||
>
|
||||
{{ getBalanceStatusText(parseFloat(row.balance_needed)) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="viewUserTransfers(row.user_id)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
size="small"
|
||||
@click="showTransferDialog(row)"
|
||||
:disabled="parseFloat(row.balance_needed) <= 0"
|
||||
>
|
||||
转账平账
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 转账对话框 -->
|
||||
<el-dialog v-model="transferDialog.visible" title="转账平账" width="500px">
|
||||
<el-form :model="transferForm" :rules="transferRules" ref="transferFormRef" label-width="100px">
|
||||
<el-form-item label="目标用户">
|
||||
<el-input :value="transferDialog.targetUser?.username + ' (' + (transferDialog.targetUser?.real_name || '未设置') + ')'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="当前缺口">
|
||||
<el-input :value="'¥' + Math.abs(parseFloat(transferDialog.targetUser?.balance_needed || 0)).toLocaleString()" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="转出用户" prop="from_user_id">
|
||||
<el-select v-model="transferForm.from_user_id" placeholder="选择转出用户" @change="onFromUserChange">
|
||||
<el-option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:label="`${user.username} (${user.real_name || '未设置'}) - 余额: ¥${user.balance || 0}`"
|
||||
:value="user.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="selectedFromUser" class="user-balance-info">
|
||||
<el-text type="info" size="small">
|
||||
当前余额: ¥{{ selectedFromUser.balance || 0 }}
|
||||
</el-text>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="转账金额" prop="amount">
|
||||
<el-input-number
|
||||
v-model="transferForm.amount"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
placeholder="请输入转账金额"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="amount-suggestion">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="setFullAmount"
|
||||
>
|
||||
设为全额平账 (¥{{ Math.abs(parseFloat(transferDialog.targetUser?.balance_needed || 0)).toLocaleString() }})
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="description">
|
||||
<el-input
|
||||
v-model="transferForm.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入转账备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="transferDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="createTransfer" :loading="transferDialog.loading">
|
||||
确认转账
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/utils/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const userList = ref([])
|
||||
const users = ref([])
|
||||
const dateInfo = reactive({
|
||||
yesterday: '',
|
||||
today: ''
|
||||
})
|
||||
|
||||
// 转账对话框相关数据
|
||||
const transferDialog = ref({
|
||||
visible: false,
|
||||
loading: false,
|
||||
targetUser: null
|
||||
})
|
||||
|
||||
const transferForm = ref({
|
||||
from_user_id: '',
|
||||
amount: null,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const selectedFromUser = ref(null)
|
||||
const transferFormRef = ref(null)
|
||||
|
||||
// 转账表单验证规则
|
||||
const transferRules = {
|
||||
from_user_id: [
|
||||
{ required: true, message: '请选择转出用户', trigger: 'change' }
|
||||
],
|
||||
amount: [
|
||||
{ required: true, message: '请输入转账金额', trigger: 'blur' },
|
||||
{ type: 'number', min: 0.01, message: '金额必须大于0.01', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
const statsData = computed(() => {
|
||||
const totalUsers = userList.value.length
|
||||
const totalYesterdayOut = userList.value.reduce((sum, user) => sum + parseFloat(user.yesterday_out_amount), 0)
|
||||
const totalTodayIn = userList.value.reduce((sum, user) => sum + parseFloat(user.today_in_amount), 0)
|
||||
const totalBalanceNeeded = userList.value.reduce((sum, user) => sum + parseFloat(user.balance_needed), 0)
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
totalYesterdayOut,
|
||||
totalTodayIn,
|
||||
totalBalanceNeeded
|
||||
}
|
||||
})
|
||||
|
||||
// 获取平账状态类型
|
||||
const getBalanceStatusType = (balanceNeeded) => {
|
||||
if (balanceNeeded > 0) return 'danger'
|
||||
if (balanceNeeded < 0) return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
// 获取平账状态文本
|
||||
const getBalanceStatusText = (balanceNeeded) => {
|
||||
if (balanceNeeded > 0) return '未平账'
|
||||
if (balanceNeeded < 0) return '有盈余'
|
||||
return '已平账'
|
||||
}
|
||||
|
||||
// 查看用户转账详情
|
||||
const viewUserTransfers = (userId) => {
|
||||
router.push(`/transfers?userId=${userId}`)
|
||||
}
|
||||
|
||||
// 显示转账对话框
|
||||
const showTransferDialog = (user) => {
|
||||
transferDialog.value.targetUser = user
|
||||
transferForm.value = {
|
||||
from_user_id: '',
|
||||
amount: null,
|
||||
description: `为用户 ${user.username} 平账转账`
|
||||
}
|
||||
selectedFromUser.value = null
|
||||
transferDialog.value.visible = true
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
// 转出用户变更时的处理
|
||||
const onFromUserChange = (userId) => {
|
||||
selectedFromUser.value = users.value.find(user => user.id === userId) || null
|
||||
}
|
||||
|
||||
// 设置全额平账金额
|
||||
const setFullAmount = () => {
|
||||
if (transferDialog.value.targetUser) {
|
||||
const balanceNeeded = Math.abs(parseFloat(transferDialog.value.targetUser.balance_needed))
|
||||
transferForm.value.amount = balanceNeeded
|
||||
}
|
||||
}
|
||||
|
||||
// 创建转账
|
||||
const createTransfer = async () => {
|
||||
if (!transferFormRef.value) return
|
||||
|
||||
try {
|
||||
await transferFormRef.value.validate()
|
||||
transferDialog.value.loading = true
|
||||
|
||||
const transferData = {
|
||||
to_user_id: transferDialog.value.targetUser.user_id,
|
||||
amount: transferForm.value.amount,
|
||||
description: transferForm.value.description,
|
||||
transfer_type: 'user_to_user'
|
||||
}
|
||||
|
||||
await api.transfers.createAdminTransfer(transferForm.value.from_user_id, transferData)
|
||||
|
||||
ElMessage.success('转账成功')
|
||||
transferDialog.value.visible = false
|
||||
fetchDailyStats() // 刷新统计数据
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.error?.message || error.response?.data?.message || error.message
|
||||
ElMessage.error('转账失败: ' + errorMessage)
|
||||
} finally {
|
||||
transferDialog.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await api.users.getUsers({ limit: 1000 })
|
||||
users.value = response.data.users || []
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取昨日转账统计数据
|
||||
const fetchDailyStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.transfers.getDailyStats()
|
||||
if (response.data.success) {
|
||||
userList.value = response.data.data.users
|
||||
dateInfo.yesterday = response.data.data.date.yesterday
|
||||
dateInfo.today = response.data.data.date.today
|
||||
} else {
|
||||
ElMessage.error(response.data.message || '获取数据失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取昨日转账统计失败:', error)
|
||||
ElMessage.error('获取数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDailyStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.daily-transfer-stats-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value.negative {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.stat-value.positive {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-info {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.amount-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-text.positive {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.amount-text.negative {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.amount-text.zero {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 转账对话框样式 */
|
||||
.user-balance-info {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.amount-suggestion {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.amount-suggestion .el-button {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.amount-suggestion .el-button:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
|
||||
/* 改进表格样式 */
|
||||
.el-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table .el-table__header {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.el-table .el-table__header th {
|
||||
background-color: #f8f9fa;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 改进按钮间距 */
|
||||
.el-table .el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 改进统计卡片样式 */
|
||||
.stats-overview .el-card__body {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 改进页面头部 */
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
</style>
|
||||
1564
src/views/Dashboard.vue
Normal file
1564
src/views/Dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
599
src/views/DatabaseMonitor.vue
Normal file
599
src/views/DatabaseMonitor.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="database-monitor">
|
||||
<div class="page-header">
|
||||
<h1>数据库监控</h1>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Refresh"
|
||||
@click="refreshData"
|
||||
:loading="loading"
|
||||
>
|
||||
刷新数据
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
:icon="Document"
|
||||
@click="showReport"
|
||||
:loading="reportLoading"
|
||||
>
|
||||
查看详细报告
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态概览卡片 -->
|
||||
<div class="status-cards" v-if="status">
|
||||
<el-card class="status-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon success" v-if="!status.poolStatus.error">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="card-icon error" v-else>
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3>连接池状态</h3>
|
||||
<p v-if="!status.poolStatus.error">正常运行</p>
|
||||
<p v-else class="error-text">{{ status.poolStatus.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="status-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon" :class="getConnectionStatusClass()">
|
||||
<el-icon><Connection /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3>连接使用率</h3>
|
||||
<p>{{ status.poolStatus.usageRate || 0 }}%</p>
|
||||
<small>{{ status.poolStatus.totalConnections - status.poolStatus.freeConnections }}/{{ status.poolStatus.connectionLimit }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="status-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon" :class="getTestStatusClass()">
|
||||
<el-icon><Timer /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3>连接测试</h3>
|
||||
<p v-if="status.connectionTest.success">{{ status.connectionTest.totalTime }}ms</p>
|
||||
<p v-else class="error-text">失败</p>
|
||||
<small v-if="status.connectionTest.success">响应正常</small>
|
||||
<small v-else>{{ status.connectionTest.error }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="status-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon info">
|
||||
<el-icon><DataLine /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3>空闲连接</h3>
|
||||
<p>{{ status.poolStatus.freeConnections || 0 }}</p>
|
||||
<small>可用连接数</small>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<div class="detail-section" v-if="status">
|
||||
<el-row :gutter="20">
|
||||
<!-- 连接池详情 -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>连接池详情</span>
|
||||
</template>
|
||||
<div class="detail-item" v-if="!status.poolStatus.error">
|
||||
<label>总连接数:</label>
|
||||
<span>{{ status.poolStatus.totalConnections }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="!status.poolStatus.error">
|
||||
<label>空闲连接:</label>
|
||||
<span>{{ status.poolStatus.freeConnections }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="!status.poolStatus.error">
|
||||
<label>获取中连接:</label>
|
||||
<span>{{ status.poolStatus.acquiringConnections }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="!status.poolStatus.error">
|
||||
<label>连接限制:</label>
|
||||
<span>{{ status.poolStatus.connectionLimit }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="!status.poolStatus.error">
|
||||
<label>使用率:</label>
|
||||
<span :class="getUsageRateClass()">{{ status.poolStatus.usageRate }}%</span>
|
||||
</div>
|
||||
<div class="error-message" v-if="status.poolStatus.error">
|
||||
<el-alert
|
||||
:title="status.poolStatus.error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 连接测试详情 -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>连接测试详情</span>
|
||||
</template>
|
||||
<div v-if="status.connectionTest.success">
|
||||
<div class="detail-item">
|
||||
<label>获取连接耗时:</label>
|
||||
<span>{{ status.connectionTest.acquireTime }}ms</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>查询耗时:</label>
|
||||
<span>{{ status.connectionTest.queryTime }}ms</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>总耗时:</label>
|
||||
<span>{{ status.connectionTest.totalTime }}ms</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>连接ID:</label>
|
||||
<span>{{ status.connectionTest.connectionId }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>服务器时间:</label>
|
||||
<span>{{ formatTime(status.connectionTest.serverTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-alert
|
||||
:title="status.connectionTest.error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
/>
|
||||
<div class="detail-item" style="margin-top: 10px;">
|
||||
<label>错误代码:</label>
|
||||
<span>{{ status.connectionTest.errorCode || 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>耗时:</label>
|
||||
<span>{{ status.connectionTest.totalTime }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 问题和建议 -->
|
||||
<div class="issues-section" v-if="status && (status.issues.length > 0 || status.recommendations.length > 0)">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12" v-if="status.issues.length > 0">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span class="issues-title">
|
||||
<el-icon><Warning /></el-icon>
|
||||
发现的问题
|
||||
</span>
|
||||
</template>
|
||||
<ul class="issues-list">
|
||||
<li v-for="(issue, index) in status.issues" :key="index" class="issue-item">
|
||||
{{ issue }}
|
||||
</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" v-if="status.recommendations.length > 0">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span class="recommendations-title">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
建议
|
||||
</span>
|
||||
</template>
|
||||
<ul class="recommendations-list">
|
||||
<li v-for="(rec, index) in status.recommendations" :key="index" class="recommendation-item">
|
||||
{{ rec }}
|
||||
</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 成功状态 -->
|
||||
<div class="success-section" v-if="status && status.issues.length === 0">
|
||||
<el-card>
|
||||
<div class="success-content">
|
||||
<el-icon class="success-icon"><CircleCheck /></el-icon>
|
||||
<h3>数据库连接正常</h3>
|
||||
<p>未发现任何问题,系统运行良好。</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 详细报告对话框 -->
|
||||
<el-dialog
|
||||
v-model="reportDialogVisible"
|
||||
title="数据库监控详细报告"
|
||||
width="80%"
|
||||
:before-close="closeReportDialog"
|
||||
>
|
||||
<div class="report-content">
|
||||
<pre>{{ reportContent }}</pre>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="closeReportDialog">关闭</el-button>
|
||||
<el-button type="primary" @click="downloadReport">下载报告</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading && !status" class="loading-container">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Refresh,
|
||||
Download,
|
||||
View,
|
||||
CircleClose,
|
||||
Connection,
|
||||
Timer,
|
||||
DataLine,
|
||||
Warning,
|
||||
InfoFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import api from '../utils/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const reportLoading = ref(false)
|
||||
const status = ref(null)
|
||||
const reportDialogVisible = ref(false)
|
||||
const reportContent = ref('')
|
||||
let autoRefreshTimer = null
|
||||
|
||||
// 获取数据库状态
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const {data} = await api.transfers.getDatabaseStatus()
|
||||
if (data.success) {
|
||||
status.value = data.data
|
||||
} else {
|
||||
ElMessage.error(data.message || '获取数据库状态失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取数据库状态失败:', error)
|
||||
ElMessage.error('获取数据库状态失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchStatus()
|
||||
}
|
||||
|
||||
// 显示详细报告
|
||||
const showReport = async () => {
|
||||
try {
|
||||
reportLoading.value = true
|
||||
const {data} = await api.transfers.getDatabaseReport()
|
||||
if (data.success) {
|
||||
reportContent.value = data.data.report
|
||||
reportDialogVisible.value = true
|
||||
} else {
|
||||
ElMessage.error(data.message || '获取数据库报告失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取数据库报告失败:', error)
|
||||
ElMessage.error('获取数据库报告失败')
|
||||
} finally {
|
||||
reportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭报告对话框
|
||||
const closeReportDialog = () => {
|
||||
reportDialogVisible.value = false
|
||||
reportContent.value = ''
|
||||
}
|
||||
|
||||
// 下载报告
|
||||
const downloadReport = () => {
|
||||
const blob = new Blob([reportContent.value], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `database-report-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('报告已下载')
|
||||
}
|
||||
|
||||
// 获取连接状态样式类
|
||||
const getConnectionStatusClass = () => {
|
||||
if (!status.value?.poolStatus.usageRate) return 'info'
|
||||
const rate = status.value.poolStatus.usageRate
|
||||
if (rate >= 90) return 'error'
|
||||
if (rate >= 70) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 获取测试状态样式类
|
||||
const getTestStatusClass = () => {
|
||||
if (!status.value?.connectionTest.success) return 'error'
|
||||
const time = status.value.connectionTest.totalTime
|
||||
if (time > 5000) return 'error'
|
||||
if (time > 1000) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 获取使用率样式类
|
||||
const getUsageRateClass = () => {
|
||||
if (!status.value?.poolStatus.usageRate) return ''
|
||||
const rate = status.value.poolStatus.usageRate
|
||||
if (rate >= 90) return 'error-text'
|
||||
if (rate >= 70) return 'warning-text'
|
||||
return 'success-text'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr) => {
|
||||
if (!timeStr) return 'N/A'
|
||||
return new Date(timeStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 启动自动刷新
|
||||
const startAutoRefresh = () => {
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
fetchStatus()
|
||||
}, 30000) // 每30秒刷新一次
|
||||
}
|
||||
|
||||
// 停止自动刷新
|
||||
const stopAutoRefresh = () => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatus()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.database-monitor {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card-icon.success {
|
||||
background-color: #f0f9ff;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.card-icon.warning {
|
||||
background-color: #fdf6ec;
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.card-icon.error {
|
||||
background-color: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.card-icon.info {
|
||||
background-color: #f4f4f5;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.card-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.card-info p {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.card-info small {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.issues-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.issues-title,
|
||||
.recommendations-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.issues-list,
|
||||
.recommendations-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.issue-item,
|
||||
.recommendation-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.issue-item:last-child,
|
||||
.recommendation-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.success-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 48px;
|
||||
color: #67c23a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-content h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.success-content p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.report-content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background-color: #f8f9fa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.report-content pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
358
src/views/Login.vue
Normal file
358
src/views/Login.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-background">
|
||||
<div class="bg-shape shape-1"></div>
|
||||
<div class="bg-shape shape-2"></div>
|
||||
<div class="bg-shape shape-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo">
|
||||
<el-icon class="logo-icon"><Setting /></el-icon>
|
||||
<h1 class="title">后台管理系统</h1>
|
||||
</div>
|
||||
<p class="subtitle">欢迎回来,请登录您的账户</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
class="login-form"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="电话号码"
|
||||
size="large"
|
||||
prefix-icon="User"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="captcha">
|
||||
<Captcha
|
||||
ref="captchaRef"
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-options">
|
||||
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
|
||||
<el-link type="primary" :underline="false">忘记密码?</el-link>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-btn"
|
||||
:loading="userStore.loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ userStore.loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
|
||||
|
||||
<div class="login-footer">
|
||||
<p>还没有账户?<el-link type="primary" :underline="false" @click="goToRegister">立即注册</el-link></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
import Captcha from '@/components/Captcha.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单引用
|
||||
const loginFormRef = ref()
|
||||
const captchaRef = ref()
|
||||
|
||||
// 登录表单数据
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
remember: false
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const loginRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入电话号码', trigger: 'blur' },
|
||||
{ min: 3, message: '用户名长度不能少于3位', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await loginFormRef.value.validate()
|
||||
// 验证验证码
|
||||
// const captchaValid = await captchaRef.value.verifyCaptcha(loginForm.captcha)
|
||||
const captchaInfo = captchaRef.value.getCaptchaInfo()
|
||||
const result = await userStore.login({
|
||||
username: loginForm.username,
|
||||
password: loginForm.password,
|
||||
captchaId: captchaInfo.captchaId,
|
||||
captchaText: captchaInfo.captchaText
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 保存记住我状态
|
||||
if (loginForm.remember) {
|
||||
localStorage.setItem('admin_remember', 'true')
|
||||
localStorage.setItem('admin_username', loginForm.username)
|
||||
} else {
|
||||
localStorage.removeItem('admin_remember')
|
||||
localStorage.removeItem('admin_username')
|
||||
}
|
||||
|
||||
// 跳转到仪表盘
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到注册页
|
||||
const goToRegister = () => {
|
||||
ElMessage.info('注册功能暂未开放,请联系管理员')
|
||||
}
|
||||
|
||||
// 组件挂载时恢复记住的用户名
|
||||
onMounted(() => {
|
||||
const remember = localStorage.getItem('admin_remember')
|
||||
const username = localStorage.getItem('admin_username')
|
||||
|
||||
if (remember === 'true' && username) {
|
||||
loginForm.username = username
|
||||
loginForm.remember = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: 10%;
|
||||
left: 10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
top: 60%;
|
||||
right: 10%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.shape-3 {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
bottom: 20%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 32px;
|
||||
color: #409eff;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form .el-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.quick-login {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.quick-login-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
width: 90%;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.quick-login-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框样式优化 */
|
||||
.login-form :deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 按钮动画 */
|
||||
.quick-login-buttons .el-button {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.quick-login-buttons .el-button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
521
src/views/Points.vue
Normal file
521
src/views/Points.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div class="points-container">
|
||||
<div class="header">
|
||||
<h2>积分管理</h2>
|
||||
<el-button type="primary" @click="showAddPointsDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
调整积分
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 积分统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.totalPoints || 0 }}</div>
|
||||
<div class="stat-label">总积分</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#409eff"><Coin /></el-icon>
|
||||
</el-card>
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.totalEarned || 0 }}</div>
|
||||
<div class="stat-label">总获得积分</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#67c23a"><TrendCharts /></el-icon>
|
||||
</el-card>
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.totalSpent || 0 }}</div>
|
||||
<div class="stat-label">总消费积分</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#e6a23c"><ShoppingCart /></el-icon>
|
||||
</el-card>
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.activeUsers || 0 }}</div>
|
||||
<div class="stat-label">活跃用户</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#f56c6c"><User /></el-icon>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="filters.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
@keyup.enter="loadPointsHistory"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="积分类型">
|
||||
<el-select v-model="filters.type" placeholder="全部类型" clearable style="display: inline-block; width: 150px;">
|
||||
<el-option label="全部类型" value="" selected />
|
||||
<!-- <el-option label="注册奖励" value="register" /> -->
|
||||
<!-- <el-option label="管理员调整" value="admin_adjust" /> -->
|
||||
<!-- <el-option label="商品兑换" value="purchase" /> -->
|
||||
<el-option label="订单退款" value="refund" />
|
||||
<!-- <el-option label="转账确认收款奖励积分" value="transfer_received" /> -->
|
||||
<!-- <el-option label="订单完成奖励" value="order_completed" /> -->
|
||||
<el-option label="积分获得" value="earn" />
|
||||
<el-option label="积分消费" value="spend" />
|
||||
<el-option label="系统调整" value="admin_adjust" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="积分变化">
|
||||
<el-select v-model="filters.change" placeholder="全部变化" clearable style="display: inline-block; width: 150px;">
|
||||
<el-option label="全部变化" value="" selected />
|
||||
<el-option label="增加" value="positive" />
|
||||
<el-option label="减少" value="negative" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadPointsHistory">搜索</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-table :data="pointsHistory" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户" width="120" />
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTypeColor(row.type)">{{ getTypeText(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="points" label="积分变化" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.points > 0 ? 'points-positive' : 'points-negative'">
|
||||
{{ row.points > 0 ? '+' : '' }}{{ row.points }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="balance_after" label="变化后余额" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="points-text">{{ row.balance_after }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" />
|
||||
<el-table-column prop="created_at" label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadPointsHistory"
|
||||
@current-change="loadPointsHistory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 调整积分对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="调整用户积分"
|
||||
width="500px"
|
||||
:before-close="closeDialog"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户" prop="userId">
|
||||
<el-select
|
||||
v-model="form.userId"
|
||||
placeholder="请选择用户"
|
||||
filterable
|
||||
:loading="userLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:label="`${user.username} (当前积分: ${user.points})`"
|
||||
:value="user.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="积分变化" prop="points">
|
||||
<el-input-number
|
||||
v-model="form.points"
|
||||
:min="-999999"
|
||||
:max="999999"
|
||||
placeholder="正数为增加,负数为减少"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整原因" prop="reason">
|
||||
<el-input
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入调整原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="closeDialog">取消</el-button>
|
||||
<el-button type="primary" @click="submitAdjustment" :loading="submitting">
|
||||
确定调整
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Coin, TrendCharts, ShoppingCart, User } from '@element-plus/icons-vue'
|
||||
import api from '@/utils/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const userLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const pointsHistory = ref([])
|
||||
const users = ref([])
|
||||
const stats = ref({})
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
type: '', // 默认显示全部类型
|
||||
change: '', // 默认显示全部变化
|
||||
dateRange: null
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
userId: null,
|
||||
points: null,
|
||||
reason: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
userId: [
|
||||
{ required: true, message: '请选择用户', trigger: 'change' }
|
||||
],
|
||||
points: [
|
||||
{ required: true, message: '请输入积分变化', trigger: 'blur' },
|
||||
{ type: 'number', message: '积分必须是数字', trigger: 'blur' }
|
||||
],
|
||||
reason: [
|
||||
{ required: true, message: '请输入调整原因', trigger: 'blur' },
|
||||
{ min: 5, max: 200, message: '原因长度在 5 到 200 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 加载积分统计
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const {data} = await api.points.getStats()
|
||||
stats.value = data.data.stats
|
||||
} catch (error) {
|
||||
console.error('加载积分统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载积分历史
|
||||
const loadPointsHistory = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
username: filters.username,
|
||||
type: filters.type
|
||||
}
|
||||
|
||||
if (filters.change) {
|
||||
params.change = filters.change
|
||||
}
|
||||
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = filters.dateRange[0]
|
||||
params.endDate = filters.dateRange[1]
|
||||
}
|
||||
|
||||
const {data} = await api.points.getHistory(params)
|
||||
pointsHistory.value = data.data.history
|
||||
pagination.total = data.data.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载积分历史失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
const searchUsers = async (query) => {
|
||||
if (!query) {
|
||||
users.value = []
|
||||
return
|
||||
}
|
||||
|
||||
userLoading.value = true
|
||||
try {
|
||||
const response = await api.users.getUsers({ search: query, limit: 20 })
|
||||
users.value = response.data.users
|
||||
} catch (error) {
|
||||
console.error('搜索用户失败:', error)
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
Object.assign(filters, {
|
||||
username: '',
|
||||
type: '',
|
||||
change: '',
|
||||
dateRange: null
|
||||
})
|
||||
pagination.page = 1
|
||||
loadPointsHistory()
|
||||
}
|
||||
|
||||
// 显示调整积分对话框
|
||||
const showAddPointsDialog = async () => {
|
||||
dialogVisible.value = true
|
||||
// 打开对话框时自动加载用户列表
|
||||
await loadAllUsers()
|
||||
}
|
||||
|
||||
// 加载所有用户
|
||||
const loadAllUsers = async () => {
|
||||
userLoading.value = true
|
||||
try {
|
||||
const response = await api.users.getUsers({ limit: 100 })
|
||||
users.value = response.data.users
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error)
|
||||
ElMessage.error('加载用户列表失败')
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
Object.assign(form, {
|
||||
userId: null,
|
||||
points: null,
|
||||
reason: ''
|
||||
})
|
||||
users.value = []
|
||||
}
|
||||
|
||||
// 提交积分调整
|
||||
const submitAdjustment = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
||||
const action = form.points > 0 ? '增加' : '减少'
|
||||
await ElMessageBox.confirm(
|
||||
`确定要为用户${action} ${Math.abs(form.points)} 积分吗?`,
|
||||
'确认操作',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
submitting.value = true
|
||||
await api.points.adjustPoints(form)
|
||||
|
||||
ElMessage.success('积分调整成功')
|
||||
closeDialog()
|
||||
loadPointsHistory()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.response?.data?.message || '积分调整失败')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取类型颜色
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
// register: 'success',
|
||||
// admin_adjust: 'warning',
|
||||
// purchase: 'danger',
|
||||
refund: 'info',
|
||||
// transfer_received: 'success',
|
||||
// order_completed: 'success',
|
||||
earn: 'success',
|
||||
spend: 'danger'
|
||||
}
|
||||
return colors[type] || 'info'
|
||||
}
|
||||
|
||||
// 获取类型文本
|
||||
const getTypeText = (type) => {
|
||||
const texts = {
|
||||
// register: '注册奖励',
|
||||
// admin_adjust: '管理员调整',
|
||||
// purchase: '商品兑换',
|
||||
refund: '订单退款',
|
||||
// transfer_received: '转账确认收款奖励积分',
|
||||
// order_completed: '订单完成奖励',
|
||||
earn: '积分获得',
|
||||
spend: '积分消费',
|
||||
admin_adjust: '系统调整'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPointsHistory()
|
||||
loadStats()
|
||||
// 确保筛选器默认值正确显示
|
||||
nextTick(() => {
|
||||
// 强制更新筛选器显示
|
||||
if (filters.type === '') {
|
||||
filters.type = ''
|
||||
}
|
||||
if (filters.change === '') {
|
||||
filters.change = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.points-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.filters {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.points-text {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.points-positive {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.points-negative {
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
807
src/views/Profile.vue
Normal file
807
src/views/Profile.vue
Normal file
@@ -0,0 +1,807 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">个人资料</h1>
|
||||
<p class="page-subtitle">管理您的个人信息和账户设置</p>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧个人信息 -->
|
||||
<el-col :xs="24" :lg="16">
|
||||
<el-card class="profile-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">基本信息</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="profileFormRef"
|
||||
:model="profileForm"
|
||||
:rules="profileRules"
|
||||
label-width="100px"
|
||||
class="profile-form"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="profileForm.username"
|
||||
placeholder="请输入用户名"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input
|
||||
v-model="profileForm.nickname"
|
||||
placeholder="请输入昵称(可选)"
|
||||
maxlength="30"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="真实姓名" prop="realName">
|
||||
<el-input
|
||||
v-model="profileForm.realName"
|
||||
placeholder="请输入真实姓名"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="身份证号" prop="idCard">
|
||||
<el-input
|
||||
v-model="profileForm.idCard"
|
||||
placeholder="请输入身份证号"
|
||||
maxlength="18"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="profileForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="微信收款码">
|
||||
<el-upload
|
||||
class="qr-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="(response) => handleQrSuccess(response, 'wechatQr')"
|
||||
:before-upload="beforeQrUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div class="qr-upload-area">
|
||||
<img v-if="profileForm.wechatQr" :src="getImageUrl(profileForm.wechatQr)" class="qr-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Upload /></el-icon>
|
||||
<div class="upload-text">点击上传微信收款码</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">支持 JPG、PNG 格式,建议尺寸 300x300,大小不超过 2MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="支付宝收款码">
|
||||
<el-upload
|
||||
class="qr-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="(response) => handleQrSuccess(response, 'alipayQr')"
|
||||
:before-upload="beforeQrUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div class="qr-upload-area">
|
||||
<img v-if="profileForm.alipayQr" :src="getImageUrl(profileForm.alipayQr)" class="qr-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Upload /></el-icon>
|
||||
<div class="upload-text">点击上传支付宝收款码</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">支持 JPG、PNG 格式,建议尺寸 300x300,大小不超过 2MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="银行卡号">
|
||||
<el-input
|
||||
v-model="profileForm.bankCard"
|
||||
placeholder="请输入银行卡号(可选)"
|
||||
maxlength="19"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="云闪付收款码">
|
||||
<el-upload
|
||||
class="qr-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="(response) => handleQrSuccess(response, 'unionpayQr')"
|
||||
:before-upload="beforeQrUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div class="qr-upload-area">
|
||||
<img v-if="profileForm.unionpayQr" :src="getImageUrl(profileForm.unionpayQr)" class="qr-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Upload /></el-icon>
|
||||
<div class="upload-text">点击上传云闪付收款码</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">支持 JPG、PNG 格式,建议尺寸 300x300,大小不超过 2MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="updateProfile"
|
||||
:loading="updating"
|
||||
>
|
||||
保存修改
|
||||
</el-button>
|
||||
<el-button @click="resetForm">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<el-card class="password-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">修改密码</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="100px"
|
||||
class="password-form"
|
||||
>
|
||||
<el-form-item label="当前密码" prop="currentPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="changePassword"
|
||||
:loading="changingPassword"
|
||||
>
|
||||
修改密码
|
||||
</el-button>
|
||||
<el-button @click="resetPasswordForm">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧头像和统计 -->
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card class="avatar-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">头像设置</span>
|
||||
</template>
|
||||
|
||||
<div class="avatar-section">
|
||||
<div class="avatar-container">
|
||||
<el-avatar
|
||||
:size="120"
|
||||
:src="getImageUrl(profileForm.avatar)"
|
||||
class="user-avatar"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleAvatarSuccess"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-button size="small" type="primary">
|
||||
<el-icon><Upload /></el-icon>
|
||||
更换头像
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<div class="upload-tip">
|
||||
支持 JPG、PNG 格式,建议尺寸 200x200,大小不超过 2MB
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 账户信息 -->
|
||||
<el-card class="account-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">账户信息</span>
|
||||
</template>
|
||||
|
||||
<div class="account-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">用户ID:</span>
|
||||
<span class="info-value">{{ userStore.user?.id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">角色:</span>
|
||||
<el-tag
|
||||
:type="userStore.user?.role === 'admin' ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
>
|
||||
{{ userStore.user?.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">注册时间:</span>
|
||||
<span class="info-value">{{ formatDate(userStore.user?.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.real_name">
|
||||
<span class="info-label">真实姓名:</span>
|
||||
<span class="info-value">{{ userStore.user.real_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.phone">
|
||||
<span class="info-label">手机号:</span>
|
||||
<span class="info-value">{{ userStore.user.phone }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.id_card">
|
||||
<span class="info-label">身份证号:</span>
|
||||
<span class="info-value">{{ userStore.user.id_card.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 收款信息 -->
|
||||
<el-card class="payment-card" shadow="never" v-if="userStore.user?.wechat_qr || userStore.user?.alipay_qr || userStore.user?.bank_card || userStore.user?.unionpay_qr">
|
||||
<template #header>
|
||||
<span class="card-title">收款信息</span>
|
||||
</template>
|
||||
|
||||
<div class="payment-info">
|
||||
<div class="info-item" v-if="userStore.user?.wechat_qr">
|
||||
<span class="info-label">微信收款码:</span>
|
||||
<span class="info-value">已设置</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.alipay_qr">
|
||||
<span class="info-label">支付宝收款码:</span>
|
||||
<span class="info-value">已设置</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.bank_card">
|
||||
<span class="info-label">银行卡号:</span>
|
||||
<span class="info-value">{{ userStore.user.bank_card.replace(/(\d{4})\d{8,11}(\d{4})/, '$1****$2') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item" v-if="userStore.user?.unionpay_qr">
|
||||
<span class="info-label">云闪付收款码:</span>
|
||||
<span class="info-value">已设置</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import api from '@/utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Upload } from '@element-plus/icons-vue'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const updating = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
|
||||
// 表单引用
|
||||
const profileFormRef = ref()
|
||||
const passwordFormRef = ref()
|
||||
|
||||
// 个人信息表单
|
||||
const profileForm = reactive({
|
||||
username: '',
|
||||
|
||||
nickname: '',
|
||||
realName: '',
|
||||
idCard: '',
|
||||
phone: '',
|
||||
wechatQr: '',
|
||||
alipayQr: '',
|
||||
bankCard: '',
|
||||
unionpayQr: '',
|
||||
avatar: ''
|
||||
})
|
||||
|
||||
// 密码表单
|
||||
const passwordForm = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const profileRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
|
||||
],
|
||||
|
||||
realName: [
|
||||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '真实姓名长度在 2 到 20 个字符', trigger: 'blur' },
|
||||
{ pattern: /^[\u4e00-\u9fa5a-zA-Z]+$/, message: '真实姓名只能包含中文和英文字母', trigger: 'blur' }
|
||||
],
|
||||
idCard: [
|
||||
{ required: true, message: '请输入身份证号', trigger: 'blur' },
|
||||
{ pattern: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/, message: '请输入正确的身份证号格式', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const passwordRules = {
|
||||
currentPassword: [
|
||||
{ required: true, message: '请输入当前密码', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]/,
|
||||
message: '密码必须包含大小写字母和数字',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== passwordForm.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 上传配置
|
||||
const uploadAction = '/api/upload'
|
||||
const uploadHeaders = computed(() => ({
|
||||
Authorization: `Bearer ${userStore.token}`
|
||||
}))
|
||||
|
||||
// 初始化表单数据
|
||||
const initFormData = () => {
|
||||
const user = userStore.user
|
||||
if (user) {
|
||||
Object.assign(profileForm, {
|
||||
username: user.username || '',
|
||||
|
||||
nickname: user.nickname || user.username || '',
|
||||
realName: user.real_name || '',
|
||||
idCard: user.id_card || '',
|
||||
phone: user.phone || '',
|
||||
wechatQr: user.wechat_qr || '',
|
||||
alipayQr: user.alipay_qr || '',
|
||||
bankCard: user.bank_card || '',
|
||||
unionpayQr: user.unionpay_qr || '',
|
||||
avatar: user.avatar || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 更新个人信息
|
||||
const updateProfile = async () => {
|
||||
try {
|
||||
await profileFormRef.value.validate()
|
||||
|
||||
updating.value = true
|
||||
|
||||
const updateData = {
|
||||
username: profileForm.username,
|
||||
|
||||
nickname: profileForm.nickname,
|
||||
realName: profileForm.realName,
|
||||
idCard: profileForm.idCard,
|
||||
phone: profileForm.phone,
|
||||
wechatQr: profileForm.wechatQr,
|
||||
alipayQr: profileForm.alipayQr,
|
||||
bankCard: profileForm.bankCard,
|
||||
unionpayQr: profileForm.unionpayQr,
|
||||
avatar: profileForm.avatar
|
||||
}
|
||||
|
||||
// 调用后端API更新个人信息
|
||||
const response = await api.users.updateUser(userStore.user.id, updateData)
|
||||
|
||||
// 更新本地用户信息
|
||||
userStore.user = { ...userStore.user, ...response.data.user }
|
||||
localStorage.setItem('admin_user', JSON.stringify(userStore.user))
|
||||
|
||||
ElMessage.success('个人信息更新成功')
|
||||
} catch (error) {
|
||||
console.error('更新个人信息失败:', error)
|
||||
const message = error.response?.data?.message || '更新失败'
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
try {
|
||||
await passwordFormRef.value.validate()
|
||||
|
||||
changingPassword.value = true
|
||||
|
||||
await api.users.changePassword({
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
newPassword: passwordForm.newPassword
|
||||
})
|
||||
|
||||
ElMessage.success('密码修改成功')
|
||||
resetPasswordForm()
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
const message = error.response?.data?.message || '修改密码失败'
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 头像上传成功
|
||||
const handleAvatarSuccess = (response) => {
|
||||
if (response.success) {
|
||||
profileForm.avatar = response.url
|
||||
|
||||
// 更新用户信息
|
||||
updateProfile()
|
||||
|
||||
ElMessage.success('头像上传成功')
|
||||
} else {
|
||||
ElMessage.error('头像上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 收款码上传成功
|
||||
const handleQrSuccess = (response, field) => {
|
||||
if (response.success) {
|
||||
profileForm[field] = response.url
|
||||
ElMessage.success('收款码上传成功')
|
||||
} else {
|
||||
ElMessage.error('收款码上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 头像上传前验证
|
||||
const beforeAvatarUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error('图片大小不能超过 2MB!')
|
||||
}
|
||||
return isImage && isLt2M
|
||||
}
|
||||
|
||||
// 收款码上传前验证
|
||||
const beforeQrUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error('图片大小不能超过 2MB!')
|
||||
}
|
||||
return isImage && isLt2M
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
initFormData()
|
||||
profileFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 重置密码表单
|
||||
const resetPasswordForm = () => {
|
||||
Object.assign(passwordForm, {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
passwordFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 组件挂载时初始化数据
|
||||
onMounted(() => {
|
||||
initFormData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.password-card,
|
||||
.avatar-card,
|
||||
.account-card,
|
||||
.payment-card,
|
||||
.stats-card {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.profile-form,
|
||||
.password-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border: 3px solid #f0f0f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.qr-uploader {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-upload-area {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr-upload-area:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.account-info,
|
||||
.payment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #2c3e50;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background-color: #e9ecef;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 992px) {
|
||||
.avatar-container {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.avatar-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
534
src/views/Settings.vue
Normal file
534
src/views/Settings.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-subtitle">管理系统配置和参数</p>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||
<!-- 基本设置 -->
|
||||
<el-tab-pane label="基本设置" name="basic">
|
||||
<el-card class="settings-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">网站基本信息</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="basicFormRef"
|
||||
:model="basicForm"
|
||||
:rules="basicRules"
|
||||
label-width="120px"
|
||||
class="settings-form"
|
||||
>
|
||||
<el-form-item label="网站名称" prop="siteName">
|
||||
<el-input
|
||||
v-model="basicForm.siteName"
|
||||
placeholder="请输入网站名称"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网站描述" prop="siteDescription">
|
||||
<el-input
|
||||
v-model="basicForm.siteDescription"
|
||||
type="textarea"
|
||||
placeholder="请输入网站描述"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网站关键词">
|
||||
<el-input
|
||||
v-model="basicForm.siteKeywords"
|
||||
placeholder="请输入网站关键词,用逗号分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网站Logo">
|
||||
<el-upload
|
||||
class="logo-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleLogoSuccess"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="basicForm.siteLogo" :src="getImageUrl(basicForm.siteLogo)" class="logo-image" />
|
||||
<div v-else class="logo-placeholder">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div class="upload-text">点击上传Logo</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">
|
||||
建议尺寸:200x60,支持 JPG、PNG 格式,大小不超过 1MB
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网站图标">
|
||||
<el-upload
|
||||
class="favicon-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleFaviconSuccess"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="basicForm.siteFavicon" :src="getImageUrl(basicForm.siteFavicon)" class="favicon-image" />
|
||||
<div v-else class="favicon-placeholder">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<div class="upload-text">上传图标</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">
|
||||
建议尺寸:32x32,支持 ICO、PNG 格式,大小不超过 500KB
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
|
||||
<el-form-item label="备案号">
|
||||
<el-input
|
||||
v-model="basicForm.icp"
|
||||
placeholder="请输入备案号(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="saveBasicSettings"
|
||||
:loading="saving"
|
||||
>
|
||||
保存设置
|
||||
</el-button>
|
||||
<el-button @click="resetBasicForm">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 功能设置 -->
|
||||
<el-tab-pane label="功能设置" name="features">
|
||||
<el-card class="settings-card" shadow="never">
|
||||
<template #header>
|
||||
<span class="card-title">功能开关</span>
|
||||
</template>
|
||||
|
||||
<div class="feature-settings">
|
||||
<div class="feature-item">
|
||||
<div class="feature-info">
|
||||
<h4 class="feature-title">用户注册</h4>
|
||||
<p class="feature-desc">允许新用户注册账户</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="featureForm.allowRegister"
|
||||
@change="saveFeatureSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-info">
|
||||
<h4 class="feature-title">评论功能</h4>
|
||||
<p class="feature-desc">允许用户对文章进行评论</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="featureForm.allowComment"
|
||||
@change="saveFeatureSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-info">
|
||||
<h4 class="feature-title">积分转账</h4>
|
||||
<p class="feature-desc">允许用户之间进行积分转账</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="featureForm.allowTransfer"
|
||||
@change="saveFeatureSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-info">
|
||||
<h4 class="feature-title">商品兑换</h4>
|
||||
<p class="feature-desc">允许用户使用积分兑换商品</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="featureForm.allowExchange"
|
||||
@change="saveFeatureSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-info">
|
||||
<h4 class="feature-title">订单评价</h4>
|
||||
<p class="feature-desc">允许用户对已购买商品进行评价</p>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="featureForm.allowReview"
|
||||
@change="saveFeatureSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import api from '@/utils/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('basic')
|
||||
const saving = ref(false)
|
||||
|
||||
// 表单引用
|
||||
const basicFormRef = ref()
|
||||
|
||||
// 基本设置表单
|
||||
const basicForm = reactive({
|
||||
siteName: '',
|
||||
siteDescription: '',
|
||||
siteKeywords: '',
|
||||
siteLogo: '',
|
||||
siteFavicon: '',
|
||||
|
||||
icp: ''
|
||||
})
|
||||
|
||||
// 功能设置表单
|
||||
const featureForm = reactive({
|
||||
allowRegister: true,
|
||||
allowTransfer: true,
|
||||
allowExchange: true,
|
||||
allowReview: true,
|
||||
allowComment: true
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 表单验证规则
|
||||
const basicRules = {
|
||||
siteName: [
|
||||
{ required: true, message: '请输入网站名称', trigger: 'blur' }
|
||||
],
|
||||
siteDescription: [
|
||||
{ required: true, message: '请输入网站描述', trigger: 'blur' }
|
||||
],
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 上传配置
|
||||
const uploadAction = '/api/upload'
|
||||
const uploadHeaders = computed(() => ({
|
||||
Authorization: `Bearer ${userStore.token}`
|
||||
}))
|
||||
|
||||
// 获取系统设置
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const {data} = await api.system.getSettings()
|
||||
const settings = data.data
|
||||
console.log(settings);
|
||||
|
||||
// 更新各个表单数据
|
||||
if (settings.basic) {
|
||||
Object.assign(basicForm, settings.basic)
|
||||
console.log(basicForm, settings.basic);
|
||||
|
||||
}
|
||||
if (settings.features) {
|
||||
Object.assign(featureForm, settings.features)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取系统设置失败:', error)
|
||||
ElMessage.error('获取系统设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存基本设置
|
||||
const saveBasicSettings = async () => {
|
||||
try {
|
||||
await basicFormRef.value.validate()
|
||||
|
||||
saving.value = true
|
||||
|
||||
await api.system.updateSettings('basic', basicForm)
|
||||
|
||||
ElMessage.success('基本设置保存成功')
|
||||
} catch (error) {
|
||||
console.error('保存基本设置失败:', error)
|
||||
const message = error.response?.data?.message || '保存失败'
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存功能设置
|
||||
const saveFeatureSettings = async () => {
|
||||
try {
|
||||
await api.system.updateSettings('features', featureForm)
|
||||
ElMessage.success('功能设置保存成功')
|
||||
} catch (error) {
|
||||
console.error('保存功能设置失败:', error)
|
||||
ElMessage.error('保存功能设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Logo上传成功
|
||||
const handleLogoSuccess = (response) => {
|
||||
if (response.success) {
|
||||
basicForm.siteLogo = response.url
|
||||
ElMessage.success('Logo上传成功')
|
||||
} else {
|
||||
ElMessage.error('Logo上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 图标上传成功
|
||||
const handleFaviconSuccess = (response) => {
|
||||
if (response.success) {
|
||||
basicForm.siteFavicon = response.url
|
||||
ElMessage.success('图标上传成功')
|
||||
} else {
|
||||
ElMessage.error('图标上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 图片上传前验证
|
||||
const beforeImageUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt1M = file.size / 1024 / 1024 < 1
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
}
|
||||
if (!isLt1M) {
|
||||
ElMessage.error('图片大小不能超过 1MB!')
|
||||
}
|
||||
return isImage && isLt1M
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetBasicForm = () => {
|
||||
fetchSettings()
|
||||
basicFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.logo-uploader,
|
||||
.favicon-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.logo-uploader :deep(.el-upload),
|
||||
.favicon-uploader :deep(.el-upload) {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.logo-uploader :deep(.el-upload:hover),
|
||||
.favicon-uploader :deep(.el-upload:hover) {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 200px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.favicon-image {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo-placeholder,
|
||||
.favicon-placeholder {
|
||||
width: 200px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.favicon-placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.favicon-placeholder .upload-icon {
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.favicon-placeholder .upload-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.feature-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.feature-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 14px;
|
||||
color: #7f8c8d;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.settings-form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
width: 150px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 150px;
|
||||
height: 45px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1135
src/views/Transfers.vue
Normal file
1135
src/views/Transfers.vue
Normal file
File diff suppressed because it is too large
Load Diff
510
src/views/UserAudit.vue
Normal file
510
src/views/UserAudit.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<template>
|
||||
<div class="user-audit-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">用户审核</h1>
|
||||
<p class="page-subtitle">审核新注册用户的证件信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 待审核用户列表 -->
|
||||
<el-card class="audit-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>待审核用户</span>
|
||||
<el-badge :value="total" class="badge" type="warning" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="auditList"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
empty-text="暂无待审核用户"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
|
||||
<el-table-column label="用户信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info">
|
||||
<div class="username">{{ row.username }}</div>
|
||||
<div class="phone">{{ row.phone }}</div>
|
||||
<div class="real-name" v-if="row.real_name">{{ row.real_name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="证件图片" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="document-images">
|
||||
<div class="image-item">
|
||||
<span class="image-label">营业执照</span>
|
||||
<el-image
|
||||
v-if="row.business_license"
|
||||
:src="getImageUrl(row.business_license)"
|
||||
:preview-src-list="[getImageUrl(row.business_license)]"
|
||||
fit="cover"
|
||||
class="document-image"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else class="no-image">未上传</span>
|
||||
</div>
|
||||
|
||||
<div class="image-item">
|
||||
<span class="image-label">身份证正面</span>
|
||||
<el-image
|
||||
v-if="row.id_card_front"
|
||||
:src="getImageUrl(row.id_card_front)"
|
||||
:preview-src-list="[getImageUrl(row.id_card_front)]"
|
||||
fit="cover"
|
||||
class="document-image"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else class="no-image">未上传</span>
|
||||
</div>
|
||||
|
||||
<div class="image-item">
|
||||
<span class="image-label">身份证反面</span>
|
||||
<el-image
|
||||
v-if="row.id_card_back"
|
||||
:src="getImageUrl(row.id_card_back)"
|
||||
:preview-src-list="[getImageUrl(row.id_card_back)]"
|
||||
fit="cover"
|
||||
class="document-image"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else class="no-image">未上传</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="收款信息" min-width="350">
|
||||
<template #default="{ row }">
|
||||
<div class="payment-info">
|
||||
<div class="payment-item">
|
||||
<span class="payment-label">微信收款码</span>
|
||||
<el-image
|
||||
v-if="row.wechat_qr"
|
||||
:src="getImageUrl(row.wechat_qr)"
|
||||
:preview-src-list="[getImageUrl(row.wechat_qr)]"
|
||||
fit="cover"
|
||||
class="payment-image"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else class="no-payment">未上传</span>
|
||||
</div>
|
||||
|
||||
<div class="payment-item">
|
||||
<span class="payment-label">支付宝收款码</span>
|
||||
<el-image
|
||||
v-if="row.alipay_qr"
|
||||
:src="getImageUrl(row.alipay_qr)"
|
||||
:preview-src-list="[getImageUrl(row.alipay_qr)]"
|
||||
fit="cover"
|
||||
class="payment-image"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else class="no-payment">未上传</span>
|
||||
</div>
|
||||
|
||||
<div class="payment-item">
|
||||
<span class="payment-label">云闪付收款码</span>
|
||||
<el-image
|
||||
v-if="row.unionpay_qr"
|
||||
:src="getImageUrl(row.unionpay_qr)"
|
||||
:preview-src-list="[getImageUrl(row.unionpay_qr)]"
|
||||
fit="cover"
|
||||
class="payment-image"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else class="no-payment">未上传</span>
|
||||
</div>
|
||||
|
||||
<div class="payment-item bank-card">
|
||||
<span class="payment-label">银行卡号</span>
|
||||
<span v-if="row.bank_card" class="bank-card-number">{{ row.bank_card }}</span>
|
||||
<span v-else class="no-payment">未填写</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="created_at" label="注册时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleApprove(row)"
|
||||
:loading="row.approving"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleReject(row)"
|
||||
:loading="row.rejecting"
|
||||
>
|
||||
拒绝
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 审核对话框 -->
|
||||
<el-dialog
|
||||
v-model="auditDialogVisible"
|
||||
:title="auditAction === 'approve' ? '审核通过' : '审核拒绝'"
|
||||
width="500px"
|
||||
@close="resetAuditForm"
|
||||
>
|
||||
<el-form :model="auditForm" label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="currentUser.username" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="currentUser.phone" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核备注">
|
||||
<el-input
|
||||
v-model="auditForm.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="auditAction === 'approve' ? '审核通过原因(可选)' : '请填写拒绝原因'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="auditDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
:type="auditAction === 'approve' ? 'success' : 'danger'"
|
||||
@click="confirmAudit"
|
||||
:loading="auditSubmitting"
|
||||
>
|
||||
确认{{ auditAction === 'approve' ? '通过' : '拒绝' }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '@/utils/api'
|
||||
import { getImageUrl } from '@/utils/config'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const auditList = ref([])
|
||||
const total = ref(0)
|
||||
const auditDialogVisible = ref(false)
|
||||
const auditSubmitting = ref(false)
|
||||
const auditAction = ref('') // 'approve' 或 'reject'
|
||||
const currentUser = ref({})
|
||||
|
||||
// 分页数据
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 审核表单
|
||||
const auditForm = reactive({
|
||||
note: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取待审核用户列表
|
||||
*/
|
||||
const loadAuditList = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.get('/users/pending-audit', {
|
||||
params: {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
auditList.value = response.data.data.users
|
||||
pagination.total = response.data.data.pagination.total
|
||||
total.value = response.data.data.pagination.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取待审核用户列表失败:', error)
|
||||
ElMessage.error('获取待审核用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理审核通过
|
||||
*/
|
||||
const handleApprove = (user) => {
|
||||
currentUser.value = user
|
||||
auditAction.value = 'approve'
|
||||
auditDialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理审核拒绝
|
||||
*/
|
||||
const handleReject = (user) => {
|
||||
currentUser.value = user
|
||||
auditAction.value = 'reject'
|
||||
auditDialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认审核
|
||||
*/
|
||||
const confirmAudit = async () => {
|
||||
try {
|
||||
auditSubmitting.value = true
|
||||
|
||||
const response = await api.put(`/users/${currentUser.value.id}/audit`, {
|
||||
action: auditAction.value,
|
||||
note: auditForm.note
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
ElMessage.success(response.data.message)
|
||||
auditDialogVisible.value = false
|
||||
await loadAuditList() // 重新加载列表
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审核失败:', error)
|
||||
ElMessage.error('审核失败')
|
||||
} finally {
|
||||
auditSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置审核表单
|
||||
*/
|
||||
const resetAuditForm = () => {
|
||||
auditForm.note = ''
|
||||
currentUser.value = {}
|
||||
auditAction.value = ''
|
||||
}
|
||||
|
||||
// getImageUrl 函数现在从 @/utils/config 中导入
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理分页大小变化
|
||||
*/
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.limit = size
|
||||
pagination.page = 1
|
||||
loadAuditList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理当前页变化
|
||||
*/
|
||||
const handleCurrentChange = (page) => {
|
||||
pagination.page = page
|
||||
loadAuditList()
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadAuditList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-audit-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.audit-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.phone {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.real-name {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.document-images {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.document-image {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dcdfe6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-image {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
background: #f5f7fa;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.payment-info {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.payment-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.payment-item.bank-card {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.payment-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.payment-image {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dcdfe6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-payment {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
background: #f5f7fa;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bank-card-number {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
background: #f5f7fa;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
1420
src/views/Users.vue
Normal file
1420
src/views/Users.vue
Normal file
File diff suppressed because it is too large
Load Diff
46
vite.config.js
Normal file
46
vite.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
// base: '/admin',
|
||||
base: '/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
host:'0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://test.zrbjr.com',
|
||||
changeOrigin: true
|
||||
},
|
||||
// '/admin': {
|
||||
// target: 'http://192.168.208.158:3000',
|
||||
// changeOrigin: true
|
||||
// },
|
||||
'/uploads': {
|
||||
target: 'https://test.zrbjr.com',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
elementPlus: ['element-plus'],
|
||||
charts: ['echarts', 'vue-echarts']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user