模板代码

This commit is contained in:
dzl
2025-09-23 15:00:43 +08:00
commit 972c7d4cc6
33 changed files with 16882 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/node_modules
/dist

80
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View 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
View 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
View 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>

View 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>

View 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">支持 JPGPNG 格式大小不超过 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

684
src/views/Announcements.vue Normal file
View 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
View 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>

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View 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支持 JPGPNG 格式大小不超过 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支持 ICOPNG 格式大小不超过 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

File diff suppressed because it is too large Load Diff

510
src/views/UserAudit.vue Normal file
View 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

File diff suppressed because it is too large Load Diff

46
vite.config.js Normal file
View 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']
}
}
}
}
})