This commit is contained in:
solosw
2026-01-05 14:11:34 +08:00
parent 35ef825371
commit 99b11b04e4
658 changed files with 99266 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
+29
View File
@@ -0,0 +1,29 @@
# untitled
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
+206
View File
@@ -0,0 +1,206 @@
# 管理员端前端项目
## 项目概述
这是一个基于Vue 3 + Element Plus的管理员端前端项目,用于管理虚拟商品交易平台。
## 技术栈
- **框架**: Vue 3 (Composition API)
- **UI库**: Element Plus
- **状态管理**: Pinia
- **路由**: Vue Router 4
- **HTTP客户端**: Axios
- **图表**: ECharts
- **构建工具**: Vite
- **样式**: CSS3 + Tailwind CSS
## 项目结构
```
src/
├── layouts/ # 布局组件
│ └── AdminLayout.vue # 管理端主布局
├── views/ # 页面组件
│ ├── Login.vue # 登录页面
│ └── admin/ # 管理端页面
│ ├── Dashboard.vue # 仪表盘
│ ├── Users.vue # 用户管理
│ ├── Scores.vue # 积分管理
│ └── orders/ # 订单管理
│ ├── PurchaseOrders.vue # 买单管理
│ └── SalesOrders.vue # 卖单管理
├── stores/ # Pinia状态管理
│ └── auth.js # 认证状态
├── router/ # 路由配置
│ └── index.js # 主路由文件
└── main.js # 应用入口
```
## 功能特性
### 🔐 认证系统
- 支持管理员和代理商登录
- JWT Token认证
- 自动路由守卫
- 权限控制
### 👥 用户管理
- 用户列表查看(支持搜索、筛选、分页)
- 用户详情查看
- 用户创建、编辑、删除(仅管理员)
- 用户状态管理
- 权限级别显示
### 📋 订单管理
- 买单管理(查看、处理订单状态)
- 卖单管理(查看、处理订单状态)
- 订单详情查看
- 支持多条件筛选
- 订单状态流转
### ⭐ 积分管理
- 积分记录查看(支持搜索、筛选)
- 积分记录创建、编辑、删除(仅管理员)
- 积分变化统计
- 用户积分查询
### 📊 数据统计
- 仪表盘数据概览
- 用户角色分布图表
- 订单趋势图表
- 实时统计数据
### 🎨 界面特性
- 响应式设计,支持移动端
- 现代化UI设计
- 暗色侧边栏
- 面包屑导航
- 页面切换动画
- 数据加载状态
## 权限说明
### 管理员权限(system_role=0
- ✅ 访问所有功能模块
- ✅ 用户管理(查看、创建、编辑、删除)
- ✅ 订单管理(查看、处理)
- ✅ 积分管理(查看、创建、编辑、删除)
- ✅ 商品管理(计划中)
- ✅ 系统配置(计划中)
### 代理商权限(system_role=1
- ✅ 仪表盘查看(仅自己邀请的用户数据)
- ✅ 用户管理(仅查看自己邀请的用户)
- ✅ 订单管理(仅查看相关用户的订单)
- ✅ 积分管理(仅查看相关用户的积分记录)
- ❌ 无创建、编辑、删除权限
- ❌ 无商品管理权限
- ❌ 无系统配置权限
## 安装和运行
### 1. 安装依赖
```bash
cd front
npm install
```
### 2. 启动开发服务器
```bash
npm run dev
```
### 3. 构建生产版本
```bash
npm run build
```
## 默认测试账户
### 管理员账户
- 手机号:`13800138000`
- 密码:`admin123`
- 权限:全部功能
### 代理商账户
- 手机号:`13800138001`
- 密码:`agent123`
- 权限:受限查看
## API配置
项目已配置axios默认baseURL为`/back`,确保后端API正确运行在对应端口。
请求拦截器会自动添加token到请求头:
```javascript
headers: {
token: localStorage.getItem('token')
}
```
响应拦截器会自动处理401未授权错误,跳转到登录页面。
## 开发规范
### 组件命名
- 页面组件:PascalCase(如:`Users.vue`
- 布局组件:PascalCase + Layout后缀(如:`AdminLayout.vue`
- 通用组件:PascalCase(如:`DataTable.vue`
### 状态管理
- 使用Pinia进行状态管理
- 按功能模块划分store
- 使用Composition API风格
### 样式规范
- 优先使用Element Plus组件样式
- 使用scoped CSS避免样式污染
- 响应式设计,移动端适配
- 使用CSS变量统一主题色彩
### 代码规范
- 使用Composition API
- 使用TypeScript类型注解(计划中)
- 函数和变量使用camelCase命名
- 常量使用UPPER_SNAKE_CASE命名
## 待开发功能
- [ ] 商品管理页面
- [ ] 系统配置管理
- [ ] 数据导出功能
- [ ] 批量操作功能
- [ ] 个人信息管理
- [ ] 操作日志记录
- [ ] 系统通知功能
- [ ] 文件上传组件
- [ ] 富文本编辑器
- [ ] 国际化支持
## 注意事项
1. **权限控制**:代理商只能看到自己邀请的用户相关数据
2. **数据过滤**:前端路由守卫 + 后端API权限双重保护
3. **错误处理**:统一的错误提示和处理机制
4. **性能优化**:按需加载、虚拟滚动等优化措施
5. **安全性**Token自动过期处理、XSS防护
## 浏览器支持
- Chrome >= 87
- Firefox >= 78
- Safari >= 14
- Edge >= 88
## 贡献指南
1. Fork项目
2. 创建特性分支
3. 提交更改
4. 推送到分支
5. 创建Pull Request
## 许可证
MIT License
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/icon.jpg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+9670
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "untitled",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@antv/g6": "^5.0.47",
"@element-plus/icons-vue": "^2.3.1",
"@fortawesome/fontawesome-free": "^6.7.2",
"@kangc/v-md-editor": "^2.3.18",
"axios": "^1.6.7",
"chart.js": "^4.4.7",
"dayjs": "^1.11.10",
"echarts": "^5.6.0",
"element-plus": "^2.5.6",
"gojs": "^3.0.22",
"markdown-it": "^14.0.0",
"neo4j-driver": "^5.28.1",
"pinia": "^2.1.7",
"prismjs": "^1.29.0",
"vue": "^3.5.13",
"vue-awesome-swiper": "^5.0.1",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.17",
"less": "^4.2.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^6.2.4",
"vite-plugin-proxy": "^0.5.0",
"vite-plugin-vue-devtools": "^7.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#409eff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#67c23a;stop-opacity:1" />
</linearGradient>
</defs>
<!-- 背景圆形 -->
<circle cx="16" cy="16" r="15" fill="url(#logoGradient)" stroke="#fff" stroke-width="1"/>
<!-- 商品图标 -->
<g fill="#fff">
<!-- 购物袋主体 -->
<path d="M9 12h14v12a2 2 0 0 1-2 2H11a2 2 0 0 1-2-2V12z"/>
<!-- 购物袋手柄 -->
<path d="M12 8a4 4 0 0 1 8 0v4h-2V8a2 2 0 0 0-4 0v4h-2V8z"/>
<!-- 装饰点 -->
<circle cx="13" cy="18" r="1"/>
<circle cx="19" cy="18" r="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 798 B

+10
View File
@@ -0,0 +1,10 @@
<script setup>
</script>
<template>
<router-view></router-view>
</template>
<style scoped>
</style>
+86
View File
@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

+3
View File
@@ -0,0 +1,3 @@
/*@tailwind base;*/
/*@tailwind components;*/
/*@tailwind utilities;*/
+384
View File
@@ -0,0 +1,384 @@
<template>
<div class="image-upload">
<el-upload
ref="uploadRef"
v-model:file-list="fileList"
:action="uploadUrl"
:headers="headers"
:before-upload="beforeUpload"
:on-success="onSuccess"
:on-error="onError"
:on-remove="onRemove"
:limit="limit"
:multiple="multiple"
:show-file-list="false"
:auto-upload="autoUpload"
accept="image/*"
class="upload-demo"
>
<div v-if="imageUrls.length < limit && !(props.previewSrcList && props.previewSrcList.length > 0 && !props.multiple)" class="upload-item">
<el-icon class="upload-icon"><Plus /></el-icon>
<div class="upload-text">上传图片</div>
</div>
</el-upload>
<!-- 图片预览 -->
<div v-if="imageUrls.length > 0 || (props.previewSrcList && props.previewSrcList.length > 0)" class="image-list">
<!-- 显示已有图片 -->
<div
v-for="(url, index) in imageUrls"
:key="`uploaded-${index}`"
class="image-item"
>
<el-image
:src="getImageUrl(url)"
:preview-src-list="props.previewSrcList.length > 0 ? props.previewSrcList : imageUrls.map(u => getImageUrl(u))"
:initial-index="index"
fit="cover"
class="image-preview"
/>
<div class="image-actions">
<el-icon class="action-icon" @click="previewImage(index)">
<ZoomIn />
</el-icon>
<el-icon class="action-icon" @click="removeImage(index)">
<Delete />
</el-icon>
</div>
</div>
<!-- 显示外部传入的预览图片(当没有上传图片时) -->
<div
v-if="imageUrls.length === 0 && props.previewSrcList && props.previewSrcList.length > 0"
v-for="(url, index) in props.previewSrcList"
:key="`preview-${index}`"
class="image-item"
>
<el-image
:src="url"
:preview-src-list="props.previewSrcList"
:initial-index="index"
fit="cover"
class="image-preview"
/>
<div class="image-actions">
<el-icon class="action-icon" @click="previewImage(index)">
<ZoomIn />
</el-icon>
<el-icon class="action-icon" @click="removeExternalImage(index)">
<Delete />
</el-icon>
</div>
</div>
</div>
<!-- 上传进度 -->
<el-progress
v-if="uploading"
:percentage="uploadProgress"
:show-text="false"
class="upload-progress"
/>
<!-- 图片预览器 -->
<el-image-viewer
v-if="showViewer"
:url-list="viewerUrls"
:initial-index="viewerIndex"
@close="closeViewer"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'
const props = defineProps({
modelValue: {
type: [String, Array],
default: ''
},
limit: {
type: Number,
default: 9
},
multiple: {
type: Boolean,
default: true
},
autoUpload: {
type: Boolean,
default: true
},
maxSize: {
type: Number,
default: 10 // MB
},
previewSrcList: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const uploadRef = ref()
const fileList = ref([])
const uploading = ref(false)
const uploadProgress = ref(0)
const showViewer = ref(false)
const viewerIndex = ref(0)
const viewerUrls = ref([])
const uploadUrl = '/back/upload'
const headers = computed(() => ({
token: localStorage.getItem('token') || ''
}))
// 图片URL列表
const imageUrls = computed({
get() {
if (!props.modelValue) return []
if (typeof props.modelValue === 'string') {
return props.modelValue ? props.modelValue.split(',').filter(url => url.trim()) : []
}
return props.modelValue || []
},
set(value) {
const newValue = props.multiple ? value.join(',') : (value[0] || '')
emit('update:modelValue', newValue)
emit('change', newValue)
}
})
// 获取完整图片URL
const getImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${window.location.origin}${url}`
}
// 上传前检查
const beforeUpload = (file) => {
// 检查文件类型
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
// 检查文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`图片大小不能超过 ${props.maxSize}MB!`)
return false
}
// 检查数量限制
if (imageUrls.value.length >= props.limit) {
ElMessage.error(`最多只能上传 ${props.limit} 张图片!`)
return false
}
uploading.value = true
uploadProgress.value = 0
return true
}
// 上传成功
const onSuccess = (response, file) => {
uploading.value = false
uploadProgress.value = 100
if (response.success) {
const newUrls = [...imageUrls.value, response.data.url]
imageUrls.value = newUrls
ElMessage.success('图片上传成功')
// 清空上传组件的文件列表,避免下次上传时出现问题
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
} else {
ElMessage.error(response.message || '上传失败')
}
}
// 上传失败
const onError = (error, file) => {
uploading.value = false
uploadProgress.value = 0
console.error('上传失败:', error)
ElMessage.error('图片上传失败,请重试')
}
// 移除文件
const onRemove = (file) => {
// 这里可以实现从服务器删除文件的逻辑
}
// 删除图片
const removeImage = (index) => {
const newUrls = [...imageUrls.value]
newUrls.splice(index, 1)
imageUrls.value = newUrls
}
// 预览图片
const previewImage = (index) => {
// 设置预览图片列表和初始索引
if (props.previewSrcList && props.previewSrcList.length > 0) {
viewerUrls.value = props.previewSrcList
} else {
viewerUrls.value = imageUrls.value.map(url => getImageUrl(url))
}
viewerIndex.value = index
showViewer.value = true
}
// 关闭图片预览
const closeViewer = () => {
showViewer.value = false
}
// 删除外部传入的图片
const removeExternalImage = (index) => {
// 通知父组件删除外部图片
emit('update:modelValue', '')
emit('change', '')
}
// 手动上传
const submit = () => {
if (uploadRef.value) {
uploadRef.value.submit()
}
}
// 清空文件
const clearFiles = () => {
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
imageUrls.value = []
}
// 监听外部传入的值变化,同步重置内部状态
watch(() => props.modelValue, (newValue, oldValue) => {
// 当外部值从有值变为空值时,清空内部状态
if ((oldValue && !newValue) || (oldValue && newValue === '')) {
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
fileList.value = []
uploading.value = false
uploadProgress.value = 0
}
}, { immediate: false })
// 暴露方法供父组件调用
defineExpose({
submit,
clearFiles
})
</script>
<style scoped>
.image-upload {
width: 100%;
}
.upload-demo {
display: inline-block;
}
.upload-item {
width: 148px;
height: 148px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: border-color 0.3s;
}
.upload-item:hover {
border-color: #409eff;
}
.upload-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.upload-text {
color: #8c939d;
font-size: 14px;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.image-item {
position: relative;
width: 148px;
height: 148px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #d9d9d9;
}
.image-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-actions {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
opacity: 0;
transition: opacity 0.3s;
}
.image-item:hover .image-actions {
opacity: 1;
}
.action-icon {
color: white;
font-size: 20px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.3s;
}
.action-icon:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.upload-progress {
margin-top: 8px;
}
</style>
+359
View File
@@ -0,0 +1,359 @@
<template>
<div class="admin-layout">
<el-container>
<!-- 侧边栏 -->
<el-aside :width="collapsed ? '64px' : '250px'" class="sidebar">
<div class="logo">
<img v-if="!collapsed" src="/logo.svg" alt="Logo" class="logo-img">
<span v-if="!collapsed" class="logo-text">管理后台</span>
<el-icon v-else class="logo-icon"><Menu /></el-icon>
</div>
<el-menu
:default-active="$route.path"
:collapse="collapsed"
:unique-opened="true"
router
class="sidebar-menu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<!-- 仪表盘 -->
<el-menu-item index="/admin/dashboard">
<el-icon><Monitor /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<!-- 用户管理 -->
<el-sub-menu index="users">
<template #title>
<el-icon><User /></el-icon>
<span>用户管理</span>
</template>
<el-menu-item index="/admin/users">用户列表</el-menu-item>
<el-menu-item v-if="authStore.isAdmin" index="/admin/warehouse/users">库存管理</el-menu-item>
</el-sub-menu>
<!-- 订单管理 -->
<el-sub-menu index="orders" v-if="authStore.isAdmin">
<template #title>
<el-icon><Document /></el-icon>
<span>订单管理</span>
</template>
<el-menu-item index="/admin/orders/purchase">买单管理</el-menu-item>
<el-menu-item index="/admin/orders/sales">卖单管理</el-menu-item>
</el-sub-menu>
<!-- 商品管理 (仅管理员) -->
<el-sub-menu v-if="authStore.isAdmin" index="products">
<template #title>
<el-icon><Box /></el-icon>
<span>商品管理</span>
</template>
<el-menu-item index="/admin/products/categories">商品分类</el-menu-item>
<el-menu-item index="/admin/products/primary">一级商品</el-menu-item>
<el-menu-item index="/admin/products/secondary">二级商品</el-menu-item>
</el-sub-menu>
<!-- 积分管理 -->
<el-sub-menu index="scores">
<template #title>
<el-icon><Star /></el-icon>
<span>积分管理</span>
</template>
<el-menu-item index="/admin/scores">积分记录</el-menu-item>
<!-- <el-menu-item v-if="authStore.isAdmin" index="/admin/scores/create">添加积分</el-menu-item>-->
</el-sub-menu>
<!-- 系统配置 (仅管理员) -->
<el-menu-item v-if="authStore.isAdmin" index="/admin/configs">
<el-icon><Setting /></el-icon>
<span>系统配置</span>
</el-menu-item>
<!-- 轮播图管理 (仅管理员) -->
<el-menu-item v-if="authStore.isAdmin" index="/admin/banners">
<el-icon><Picture /></el-icon>
<span>轮播图管理</span>
</el-menu-item>
<!-- 个人信息 -->
<el-menu-item index="/admin/profile">
<el-icon><User /></el-icon>
<span>个人信息</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<!-- 顶部导航 -->
<el-header class="header">
<div class="header-left">
<el-button
type="text"
class="collapse-btn"
@click="toggleSidebar"
>
<el-icon><Expand v-if="collapsed" /><Fold v-else /></el-icon>
</el-button>
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="item in breadcrumbs"
:key="item.path"
:to="item.path"
>
{{ item.name }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<!-- 用户信息 -->
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-avatar :size="32" :src="authStore.user?.avatar">
<el-icon><UserFilled /></el-icon>
</el-avatar>
<span class="username">{{ authStore.user?.customer_name || authStore.user?.real_name }}</span>
<span class="user-role">{{ authStore.userRole }}</span>
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人信息</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主内容 -->
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const collapsed = ref(false)
// 切换侧边栏
const toggleSidebar = () => {
collapsed.value = !collapsed.value
}
// 面包屑导航
const breadcrumbs = computed(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title)
const first = matched[0]
if (!first || first.name !== 'Admin') {
matched.unshift({ path: '/admin/dashboard', meta: { title: '首页' } })
}
return matched.map(item => ({
name: item.meta.title,
path: item.path
}))
})
// 处理用户下拉菜单命令
const handleCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/admin/profile')
break
case 'logout':
try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
authStore.logout()
ElMessage.success('退出登录成功')
router.push('/login')
} catch {
// 用户取消
}
break
}
}
// 监听路由变化,在小屏幕下自动收起侧边栏
watch(route, () => {
if (window.innerWidth < 992) {
collapsed.value = true
}
})
</script>
<style scoped>
.admin-layout {
height: 100vh;
}
.sidebar {
background-color: #304156;
transition: width 0.28s;
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #2b3a4b;
border-bottom: 1px solid #1d2b3b;
}
.logo-img {
height: 32px;
width: 32px;
margin-right: 8px;
}
.logo-text {
color: #fff;
font-size: 16px;
font-weight: 600;
}
.logo-icon {
color: #fff;
font-size: 20px;
}
.sidebar-menu {
border: none;
height: calc(100vh - 60px);
overflow-y: auto;
}
.sidebar-menu:not(.el-menu--collapse) {
width: 250px;
}
.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 3px rgba(0, 0, 0, 0.12);
}
.header-left {
display: flex;
align-items: center;
}
.collapse-btn {
margin-right: 20px;
font-size: 18px;
color: #5a5e66;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.3s;
}
.user-info:hover {
background-color: #f5f7fa;
}
.username {
margin-left: 8px;
color: #303133;
font-weight: 500;
}
.user-role {
margin-left: 8px;
color: #909399;
font-size: 12px;
background-color: #f0f9ff;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #b3d8ff;
}
.arrow-down {
margin-left: 8px;
color: #909399;
font-size: 12px;
}
.main-content {
background-color: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
/* 页面切换动画 */
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(-30px);
}
/* 响应式设计 */
@media (max-width: 992px) {
.header {
padding: 0 16px;
}
.main-content {
padding: 16px;
}
.username {
display: none;
}
.user-role {
display: none;
}
}
</style>
+71
View File
@@ -0,0 +1,71 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from "./router/index.js";
import ElementPlus, {ElMessage} from 'element-plus'
import 'element-plus/dist/index.css'
import 'dayjs/locale/zh-cn'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import axios from 'axios'
import VueMarkdownEditor from '@kangc/v-md-editor';
import '@kangc/v-md-editor/lib/style/base-editor.css';
import vuepressTheme from '@kangc/v-md-editor/lib/theme/vuepress.js';
import '@kangc/v-md-editor/lib/theme/style/vuepress.css';
import Prism from 'prismjs';
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import "./assets/main.css"
import '@fortawesome/fontawesome-free/css/all.min.css';
VueMarkdownEditor.use(vuepressTheme, {
Prism,
});
const app = createApp(App);
const pinia = createPinia();
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, {
locale: zhCn,
})
app.use(VueMarkdownEditor);
axios.defaults.baseURL = '/back';
axios.loadData = async function (url) {
const resp = await axios.get(url);
return resp.data;
};
axios.interceptors.request.use(function (config) {
if(localStorage.getItem("token"))
config.headers.token = localStorage.getItem("token");
return config;
}, function (error) {
return Promise.reject(error);
});
// 响应拦截器
axios.interceptors.response.use(
function (response) {
if(response.data.code==401){
ElMessage.error( "登陆异常")
router.push("/login")
response.status.code=401
return response;
}
if( response.data.success== false){
ElMessage.error( response.data.message)
}
// 对响应数据做点什么
return response;
},
function (error) {
return Promise.reject(error);
}
);
app.config.globalProperties.$http = axios;
app.use(pinia);
app.use(router);
app.use(ElementPlus);
app.mount("#app");
+158
View File
@@ -0,0 +1,158 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "@/stores/auth";
// 导入页面组件
import Login from "@/views/Login.vue";
import AdminLayout from "@/layouts/AdminLayout.vue";
import Dashboard from "@/views/admin/Dashboard.vue";
import Users from "@/views/admin/Users.vue";
import PurchaseOrders from "@/views/admin/orders/PurchaseOrders.vue";
import SalesOrders from "@/views/admin/orders/SalesOrders.vue";
import Scores from "@/views/admin/Scores.vue";
import Categories from "@/views/admin/products/Categories.vue";
import PrimaryProducts from "@/views/admin/products/PrimaryProducts.vue";
import SecondaryProducts from "@/views/admin/products/SecondaryProducts.vue";
import SystemConfigs from "@/views/admin/SystemConfigs.vue";
import Banners from "@/views/admin/Banners.vue";
import Profile from "@/views/Profile.vue";
import UserWarehouses from "@/views/admin/UserWarehouses.vue";
const routes = [
{
path: "/",
redirect: "/admin/dashboard",
},
{
path: "/login",
name: "Login",
component: Login,
meta: { title: "登录" },
},
{
path: "/admin",
name: "Admin",
component: AdminLayout,
redirect: "/admin/dashboard",
meta: {
title: "管理后台",
requiresAuth: true,
},
children: [
{
path: "dashboard",
name: "Dashboard",
component: Dashboard,
meta: { title: "仪表盘", requiresAuth: true },
},
{
path: "users",
name: "Users",
component: Users,
meta: { title: "用户管理", requiresAuth: true },
},
{
path: "warehouse/users",
name: "UserWarehouses",
component: UserWarehouses,
meta: { title: "用户库存管理", requiresAuth: true, adminOnly: true },
},
{
path: "orders/purchase",
name: "PurchaseOrders",
component: PurchaseOrders,
meta: { title: "买单管理", requiresAuth: true, adminOnly: true },
},
{
path: "orders/sales",
name: "SalesOrders",
component: SalesOrders,
meta: { title: "卖单管理", requiresAuth: true, adminOnly: true },
},
{
path: "scores",
name: "Scores",
component: Scores,
meta: { title: "积分管理", requiresAuth: true },
},
{
path: "products/categories",
name: "ProductCategories",
component: Categories,
meta: { title: "商品分类", requiresAuth: true, adminOnly: true },
},
{
path: "products/primary",
name: "PrimaryProducts",
component: PrimaryProducts,
meta: { title: "一级商品", requiresAuth: true, adminOnly: true },
},
{
path: "products/secondary",
name: "SecondaryProducts",
component: SecondaryProducts,
meta: { title: "二级商品", requiresAuth: true, adminOnly: true },
},
{
path: "configs",
name: "SystemConfigs",
component: SystemConfigs,
meta: { title: "系统配置", requiresAuth: true, adminOnly: true },
},
{
path: "banners",
name: "Banners",
component: Banners,
meta: { title: "轮播图管理", requiresAuth: true, adminOnly: true },
},
{
path: "profile",
name: "Profile",
component: Profile,
meta: { title: "个人信息", requiresAuth: true },
},
],
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
// 检查是否需要登录
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
next("/login");
return;
}
// 如果已登录且访问登录页,重定向到管理后台
if (to.path === "/login" && authStore.isLoggedIn) {
next("/admin/dashboard");
return;
}
// 权限检查
if (to.meta.requiresAuth && authStore.isLoggedIn) {
// 检查是否为管理员或代理商
if (authStore.user?.system_role > 1) {
// 普通用户无权访问管理后台
next("/login");
return;
}
// 检查是否为管理员专用页面
if (to.meta.adminOnly && !authStore.isAdmin) {
// 非管理员无权访问管理员专用页面
next("/admin/dashboard");
return;
}
}
next();
});
export default router;
+104
View File
@@ -0,0 +1,104 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import axios from 'axios'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.system_role === 0)
const isAgent = computed(() => user.value?.system_role === 1)
const userRole = computed(() => {
if (!user.value) return ''
switch (user.value.system_role) {
case 0: return '管理员'
case 1: return '代理商'
case 2: return '普通用户'
default: return '未知'
}
})
// 登录
const login = async (credentials) => {
try {
const response = await axios.post('/auth/login', credentials)
if (response.data.success) {
const { token: newToken, user: userData } = response.data.data
token.value = newToken
user.value = userData
localStorage.setItem('token', newToken)
localStorage.setItem('user', JSON.stringify(userData))
return userData
} else {
throw new Error(response.data.message || '登录失败')
}
} catch (error) {
console.error('登录错误:', error)
throw error
}
}
// 登出
const logout = () => {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
// 获取当前用户信息
const getCurrentUser = async () => {
try {
const response = await axios.get('/auth/current')
if (response.data.success) {
user.value = response.data.data
localStorage.setItem('user', JSON.stringify(response.data.data))
return response.data.data
}
} catch (error) {
console.error('获取用户信息失败:', error)
logout() // 如果获取用户信息失败,清除登录状态
throw error
}
}
// 检查权限
const hasPermission = (requiredRole = null) => {
if (!user.value) return false
// 管理员拥有所有权限
if (user.value.system_role === 0) return true
// 如果没有指定角色要求,只要登录即可
if (requiredRole === null) return true
// 检查特定角色权限
return user.value.system_role === requiredRole
}
// 更新用户信息
const updateUserInfo = (userData) => {
user.value = userData
localStorage.setItem('user', JSON.stringify(userData))
}
return {
token,
user,
isLoggedIn,
isAdmin,
isAgent,
userRole,
login,
logout,
getCurrentUser,
hasPermission,
updateUserInfo
}
})
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h1>管理员登录</h1>
<p>虚拟商品交易平台管理系统</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
@keyup.enter="handleLogin"
>
<el-form-item prop="phone">
<el-input
v-model="loginForm.phone"
placeholder="请输入手机号"
prefix-icon="Phone"
size="large"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
size="large"
show-password
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-button"
:loading="loading"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<!-- <div class="login-footer">-->
<!-- <div class="demo-accounts">-->
<!-- <h4>测试账户</h4>-->
<!-- <div class="account-item">-->
<!-- <span class="label">管理员</span>-->
<!-- <el-button type="text" @click="fillAccount('admin')">13800138000 / admin123</el-button>-->
<!-- </div>-->
<!-- <div class="account-item">-->
<!-- <span class="label">代理商</span>-->
<!-- <el-button type="text" @click="fillAccount('agent')">13800138001 / agent123</el-button>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loginFormRef = ref()
const loading = ref(false)
const loginForm = reactive({
phone: '',
password: ''
})
const loginRules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
]
}
// 填充测试账户
const fillAccount = (type) => {
if (type === 'admin') {
loginForm.phone = '13800138000'
loginForm.password = 'admin123'
} else if (type === 'agent') {
loginForm.phone = '13800138001'
loginForm.password = 'agent123'
}
}
// 处理登录
const handleLogin = async () => {
if (!loginFormRef.value) return
const valid = await loginFormRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.login(loginForm)
ElMessage.success('登录成功')
if (authStore.isAdmin) {
router.push('/admin/dashboard')
}else {
router.push('/admin/users')
}
} catch (error) {
console.error('登录失败:', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-box {
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
font-size: 28px;
color: #2c3e50;
margin: 0 0 8px 0;
font-weight: 600;
}
.login-header p {
color: #7f8c8d;
margin: 0;
font-size: 14px;
}
.login-form {
margin-bottom: 20px;
}
.login-button {
width: 100%;
height: 48px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
}
.login-footer {
border-top: 1px solid #ebeef5;
padding-top: 20px;
}
.demo-accounts h4 {
margin: 0 0 12px 0;
color: #606266;
font-size: 14px;
font-weight: 500;
}
.account-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
}
.account-item .label {
color: #909399;
min-width: 60px;
}
.account-item .el-button {
padding: 0;
font-size: 13px;
color: #409eff;
}
.account-item .el-button:hover {
color: #66b1ff;
}
</style>
+516
View File
@@ -0,0 +1,516 @@
<template>
<div class="profile-page">
<div class="page-header">
<h2>个人信息</h2>
</div>
<div class="profile-content">
<!-- 个人信息卡片 -->
<el-card class="profile-card" shadow="never">
<template #header>
<div class="card-header">
<span>基本信息</span>
</div>
</template>
<el-form
ref="profileFormRef"
:model="profileForm"
:rules="profileRules"
label-width="100px"
class="profile-form"
>
<!-- 头像上传 -->
<el-form-item label="头像">
<div class="avatar-upload">
<div class="avatar-container">
<el-image
v-if="profileForm.avatar"
:src="getImageUrl(profileForm.avatar)"
fit="cover"
class="avatar-preview"
/>
<div v-else class="avatar-placeholder">
<el-icon><User /></el-icon>
</div>
</div>
<ImageUpload
v-model="profileForm.avatar"
:limit="1"
:multiple="false"
class="avatar-uploader"
>
<el-button type="primary" size="small">
<el-icon><Upload /></el-icon>
更换头像
</el-button>
</ImageUpload>
</div>
</el-form-item>
<!-- 基本信息 -->
<el-form-item label="手机号">
<el-input
v-model="profileForm.phone"
disabled
placeholder="手机号码"
/>
</el-form-item>
<el-form-item label="客户姓名" prop="customer_name">
<el-input
v-model="profileForm.customer_name"
placeholder="请输入客户姓名"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="真实姓名" prop="real_name">
<el-input
v-model="profileForm.real_name"
placeholder="请输入真实姓名"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="公司全称" prop="company_name">
<el-input
v-model="profileForm.company_name"
placeholder="请输入公司全称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="个人介绍" prop="personal_intro">
<el-input
v-model="profileForm.personal_intro"
type="textarea"
:rows="4"
placeholder="请输入个人介绍"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="身份码">
<el-input
v-model="profileForm.identity_code"
disabled
placeholder="系统自动生成"
/>
</el-form-item>
<el-form-item label="推荐人身份码">
<el-input
v-model="profileForm.referrer_identity_code"
disabled
placeholder="推荐人身份码"
/>
</el-form-item>
<!-- 证件图片 -->
<el-form-item label="营业执照">
<ImageUpload
v-model="profileForm.business_license"
:limit="1"
:multiple="false"
/>
</el-form-item>
<el-form-item label="身份证正面">
<ImageUpload
v-model="profileForm.id_card_front"
:limit="1"
:multiple="false"
/>
</el-form-item>
<el-form-item label="身份证反面">
<ImageUpload
v-model="profileForm.id_card_back"
:limit="1"
:multiple="false"
/>
</el-form-item>
<!-- 收款码 -->
<el-form-item label="主收款码">
<ImageUpload
v-model="profileForm.main_payment_code"
:limit="1"
:multiple="false"
/>
</el-form-item>
<el-form-item label="副收款码">
<ImageUpload
v-model="profileForm.backup_payment_code"
:limit="1"
:multiple="false"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="updateProfile">
保存修改
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 修改密码卡片 -->
<el-card class="password-card" shadow="never">
<template #header>
<div class="card-header">
<span>修改密码</span>
</div>
</template>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="100px"
class="password-form"
>
<el-form-item label="当前密码" prop="current_password">
<el-input
v-model="passwordForm.current_password"
type="password"
placeholder="请输入当前密码"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="new_password">
<el-input
v-model="passwordForm.new_password"
type="password"
placeholder="请输入新密码"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirm_password">
<el-input
v-model="passwordForm.confirm_password"
type="password"
placeholder="请确认新密码"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="passwordSubmitting" @click="updatePassword">
修改密码
</el-button>
<el-button @click="resetPasswordForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, Upload } from '@element-plus/icons-vue'
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import ImageUpload from '@/components/ImageUpload.vue'
const authStore = useAuthStore()
// 响应式数据
const submitting = ref(false)
const passwordSubmitting = ref(false)
const profileFormRef = ref()
const passwordFormRef = ref()
// 个人信息表单
const profileForm = reactive({
phone: '',
customer_name: '',
real_name: '',
avatar: '',
company_name: '',
personal_intro: '',
identity_code: '',
referrer_identity_code: '',
business_license: '',
id_card_front: '',
id_card_back: '',
main_payment_code: '',
backup_payment_code: ''
})
// 密码修改表单
const passwordForm = reactive({
current_password: '',
new_password: '',
confirm_password: ''
})
// 个人信息验证规则
const profileRules = {
customer_name: [
{ required: true, message: '请输入客户姓名', trigger: 'blur' },
{ max: 50, message: '客户姓名不能超过50个字符', trigger: 'blur' }
],
real_name: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ max: 50, message: '真实姓名不能超过50个字符', trigger: 'blur' }
],
company_name: [
{ max: 100, message: '公司名称不能超过100个字符', trigger: 'blur' }
],
personal_intro: [
{ max: 500, message: '个人介绍不能超过500个字符', trigger: 'blur' }
]
}
// 密码验证规则
const passwordRules = {
current_password: [
{ required: true, message: '请输入当前密码', trigger: 'blur' }
],
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6个字符', trigger: 'blur' },
{ max: 20, message: '密码长度不能超过20个字符', trigger: 'blur' }
],
confirm_password: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== passwordForm.new_password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
// 获取当前用户信息
const getCurrentUser = async () => {
try {
const response = await axios.get('/current-user')
if (response.data.success) {
const userData = response.data.data
Object.assign(profileForm, {
phone: userData.phone || '',
customer_name: userData.customer_name || '',
real_name: userData.real_name || '',
avatar: userData.avatar || '',
company_name: userData.company_name || '',
personal_intro: userData.personal_intro || '',
identity_code: userData.identity_code || '',
referrer_identity_code: userData.referrer_identity_code || '',
business_license: userData.business_license_image || '',
id_card_front: userData.id_card_front_image || '',
id_card_back: userData.id_card_back_image || '',
main_payment_code: userData.main_payment_qr_image || '',
backup_payment_code: userData.sub_payment_qr_image || ''
})
}
} catch (error) {
console.error('获取用户信息失败:', error)
ElMessage.error('获取用户信息失败')
}
}
// 获取图片完整URL
const getImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${window.location.origin}${url}`
}
// 更新个人信息
const updateProfile = async () => {
if (!profileFormRef.value) return
const valid = await profileFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const response = await axios.put('/update-profile', {
customer_name: profileForm.customer_name,
real_name: profileForm.real_name,
avatar: profileForm.avatar,
company_name: profileForm.company_name,
personal_intro: profileForm.personal_intro,
business_license: profileForm.business_license,
id_card_front: profileForm.id_card_front,
id_card_back: profileForm.id_card_back,
main_payment_code: profileForm.main_payment_code,
backup_payment_code: profileForm.backup_payment_code
})
if (response.data.success) {
ElMessage.success('个人信息更新成功')
// 更新全局用户信息
authStore.updateUserInfo(response.data.data)
}
} catch (error) {
console.error('更新个人信息失败:', error)
ElMessage.error('更新个人信息失败')
} finally {
submitting.value = false
}
}
// 修改密码
const updatePassword = async () => {
if (!passwordFormRef.value) return
const valid = await passwordFormRef.value.validate().catch(() => false)
if (!valid) return
passwordSubmitting.value = true
try {
const response = await axios.put('/change-password', {
current_password: passwordForm.current_password,
new_password: passwordForm.new_password
})
if (response.data.success) {
ElMessage.success('密码修改成功')
resetPasswordForm()
// 提醒用户重新登录
await ElMessageBox.confirm(
'密码已修改成功,为了安全起见,请重新登录。',
'提示',
{
confirmButtonText: '重新登录',
cancelButtonText: '稍后再说',
type: 'success'
}
)
// 退出登录
authStore.logout()
}
} catch (error) {
console.error('修改密码失败:', error)
const message = error.response?.data?.message || '修改密码失败'
ElMessage.error(message)
} finally {
passwordSubmitting.value = false
}
}
// 重置密码表单
const resetPasswordForm = () => {
Object.assign(passwordForm, {
current_password: '',
new_password: '',
confirm_password: ''
})
if (passwordFormRef.value) {
passwordFormRef.value.resetFields()
}
}
// 组件挂载后获取用户信息
onMounted(() => {
getCurrentUser()
})
</script>
<style scoped>
.profile-page {
padding: 0;
}
.page-header {
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.profile-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.profile-card,
.password-card {
margin-bottom: 20px;
}
.card-header {
font-weight: 600;
color: #303133;
}
.profile-form,
.password-form {
max-width: 600px;
}
.avatar-upload {
display: flex;
align-items: center;
gap: 20px;
}
.avatar-container {
position: relative;
}
.avatar-preview {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid #e4e7ed;
}
.avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px dashed #dcdfe6;
display: flex;
align-items: center;
justify-content: center;
background-color: #fafafa;
color: #c0c4cc;
font-size: 24px;
}
.avatar-uploader {
flex: 1;
}
/* 响应式设计 */
@media (max-width: 768px) {
.profile-form,
.password-form {
max-width: 100%;
}
.avatar-upload {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+424
View File
@@ -0,0 +1,424 @@
<template>
<div class="banners-page">
<div class="page-header">
<h2>轮播图管理</h2>
<el-button type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon>
添加轮播图
</el-button>
</div>
<!-- 轮播图列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="banners"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column label="图片" width="120">
<template #default="{ row }">
<el-image
v-if="row.image_url"
:src="(row.image_url)"
class="banner-preview"
fit="cover"
preview-teleported
/>
<span v-else class="no-image">无图片</span>
</template>
</el-table-column>
<!-- <el-table-column prop="link_url" label="链接地址" min-width="200" show-overflow-tooltip />-->
<!-- -->
<!-- <el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />-->
<!-- -->
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag
:type="row.status === 1 ? 'success' : 'danger'"
size="small"
>
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<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="text" size="small" @click="showEditDialog(row)">
编辑
</el-button>
<el-button
type="text"
size="small"
class="danger-text"
@click="deleteBanner(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getBanners"
@current-change="getBanners"
/>
</div>
</el-card>
<!-- 创建/编辑轮播图对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '添加轮播图' : '编辑轮播图'"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="bannerFormRef"
:model="bannerForm"
:rules="bannerRules"
label-width="100px"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="bannerForm.title"
placeholder="请输入轮播图标题"
/>
</el-form-item>
<el-form-item label="图片" prop="image_url">
<ImageUpload
v-model="bannerForm.image_url"
:limit="1"
accept="image/*"
list-type="picture-card"
/>
</el-form-item>
<!-- <el-form-item label="链接地址" prop="link_url">-->
<!-- <el-input-->
<!-- v-model="bannerForm.link_url"-->
<!-- placeholder="请输入跳转链接(可选)"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- -->
<!-- <el-form-item label="描述" prop="description">-->
<!-- <el-input-->
<!-- v-model="bannerForm.description"-->
<!-- type="textarea"-->
<!-- :rows="3"-->
<!-- placeholder="请输入描述信息(可选)"-->
<!-- />-->
<!-- </el-form-item>-->
<el-form-item label="排序" prop="sort_order">
<el-input-number
v-model="bannerForm.sort_order"
:min="0"
:max="9999"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="bannerForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitBanner">
{{ dialogMode === 'create' ? '添加' : '保存' }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import ImageUpload from '@/components/ImageUpload.vue'
import axios from 'axios'
// 响应式数据
const loading = ref(false)
const submitLoading = ref(false)
const banners = ref([])
const dialogVisible = ref(false)
const dialogMode = ref('create') // create | edit
// 分页
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 轮播图表单
const bannerForm = reactive({
id: null,
title: '',
image_url: '',
link_url: '',
description: '',
sort_order: 0,
status: 1
})
// 表单引用
const bannerFormRef = ref()
// 表单验证规则
const bannerRules = {
title: [
{ required: true, message: '请输入轮播图标题', trigger: 'blur' }
],
image_url: [
{ required: true, message: '请上传轮播图图片', trigger: 'blur' }
]
}
// 获取轮播图列表
const getBanners = async () => {
loading.value = true
try {
const { data } = await axios.get('/admin/banners', {
params: {
page: pagination.page,
page_size: pagination.pageSize
}
})
if (data.success) {
banners.value = data.data.list || []
pagination.total = data.data.total || 0
} else {
ElMessage.error(data.message || '获取轮播图列表失败')
}
} catch (error) {
console.error('获取轮播图列表失败:', error)
ElMessage.error('获取轮播图列表失败')
} finally {
loading.value = false
}
}
// 显示创建对话框
const showCreateDialog = () => {
dialogMode.value = 'create'
resetForm()
dialogVisible.value = true
}
// 显示编辑对话框
const showEditDialog = (banner) => {
dialogMode.value = 'edit'
Object.assign(bannerForm, {
id: banner.id,
title: banner.title,
image_url: banner.image_url,
link_url: banner.link_url || '',
description: banner.description || '',
sort_order: banner.sort_order || 0,
status: banner.status
})
dialogVisible.value = true
}
// 重置表单
const resetForm = () => {
Object.assign(bannerForm, {
id: null,
title: '',
image_url: '',
link_url: '',
description: '',
sort_order: 0,
status: 1
})
bannerFormRef.value?.clearValidate()
}
// 提交轮播图
const submitBanner = async () => {
if (!bannerFormRef.value) return
const valid = await bannerFormRef.value.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
const url = dialogMode.value === 'create'
? '/admin/banners'
: `/admin/banners/${bannerForm.id}`
const method = dialogMode.value === 'create' ? 'post' : 'put'
const { data } = await axios[method](url, {
title: bannerForm.title,
image_url: bannerForm.image_url,
link_url: bannerForm.link_url,
description: bannerForm.description,
sort_order: bannerForm.sort_order,
status: bannerForm.status
})
if (data.success) {
ElMessage.success(dialogMode.value === 'create' ? '轮播图添加成功' : '轮播图更新成功')
dialogVisible.value = false
getBanners()
} else {
ElMessage.error(data.message || '操作失败')
}
} catch (error) {
console.error('提交轮播图失败:', error)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
}
// 删除轮播图
const deleteBanner = async (banner) => {
try {
await ElMessageBox.confirm(
`确定要删除轮播图 "${banner.title}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const { data } = await axios.delete(`/admin/banners/${banner.id}`)
if (data.success) {
ElMessage.success('轮播图删除成功')
getBanners()
} else {
ElMessage.error(data.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除轮播图失败:', error)
ElMessage.error('删除失败')
}
}
}
// 获取完整图片URL
const getFullImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${axios.defaults.baseURL}${url}`
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 组件挂载
onMounted(() => {
getBanners()
})
</script>
<style scoped>
.banners-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
font-size: 24px;
color: #333;
}
.table-card {
margin-bottom: 20px;
}
.banner-preview {
width: 80px;
height: 50px;
border-radius: 4px;
}
.no-image {
color: #999;
font-size: 12px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.danger-text {
color: #f56c6c;
}
.danger-text:hover {
color: #f56c6c !important;
}
.dialog-footer {
text-align: right;
}
:deep(.el-table th) {
background-color: #fafafa;
}
:deep(.el-card__body) {
padding: 20px;
}
</style>
+606
View File
@@ -0,0 +1,606 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<h2>仪表盘</h2>
<p>欢迎回来{{ authStore.user?.customer_name || authStore.user?.real_name }}</p>
</div>
<!-- 数据统计卡片 -->
<div class="stats-grid">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon user-icon">
<el-icon><User /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ stats.totalUsers || 0 }}</div>
<div class="stat-label">总用户数</div>
</div>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon order-icon">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ stats.totalOrders || 0 }}</div>
<div class="stat-label">总订单数</div>
</div>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon product-icon">
<el-icon><Box /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">{{ stats.totalProducts || 0 }}</div>
<div class="stat-label">商品总数</div>
</div>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon money-icon">
<el-icon><Money /></el-icon>
</div>
<div class="stat-info">
<div class="stat-number">¥{{ formatAmount(stats.totalAmount) }}</div>
<div class="stat-label">交易总额</div>
</div>
</div>
</el-card>
</div>
<!-- 用户搜索统计 -->
<div class="user-search-section">
<el-card>
<template #header>
<div class="card-header">
<span>用户统计查询</span>
</div>
</template>
<div class="search-form">
<el-row :gutter="20">
<el-col :span="8">
<el-input
v-model="userSearchQuery"
placeholder="输入手机号或用户姓名"
clearable
@keyup.enter="searchUserStats"
>
<template #prepend>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-col>
<el-col :span="10">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
/>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="searchUserStats" :loading="userSearchLoading">
查询统计
</el-button>
</el-col>
</el-row>
</div>
<!-- 用户统计结果 -->
<div v-if="selectedUserStats" class="user-stats-result">
<el-divider content-position="left">用户信息</el-divider>
<el-descriptions :column="3" border>
<el-descriptions-item label="客户姓名">{{ selectedUserStats.user.customer_name }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ selectedUserStats.user.real_name }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ selectedUserStats.user.phone }}</el-descriptions-item>
<el-descriptions-item label="用户角色">{{ getUserRoleName(selectedUserStats.user.system_role) }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatTime(selectedUserStats.user.created_at) }}</el-descriptions-item>
<el-descriptions-item label="公司名称">{{ selectedUserStats.user.company_name || '未填写' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">
统计数据
<span v-if="dateRange && dateRange.length === 2" style="font-size: 12px; color: #909399; margin-left: 10px;">
({{ dateRange[0] }} {{ dateRange[1] }})
</span>
</el-divider>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="采购订单数" :value="selectedUserStats.stats.purchase_orders_count || 0" />
</el-col>
<el-col :span="6">
<el-statistic title="销售订单数" :value="selectedUserStats.stats.sales_orders_count || 0" />
</el-col>
<el-col :span="6">
<el-statistic title="采购总金额" :value="selectedUserStats.stats.total_purchase_amount || 0" prefix="¥" :precision="2" />
</el-col>
<el-col :span="6">
<el-statistic title="销售总金额" :value="selectedUserStats.stats.total_sales_amount || 0" prefix="¥" :precision="2" />
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="6">
<el-statistic title="库存商品种类" :value="selectedUserStats.stats.warehouse_items_count || 0" />
</el-col>
<el-col :span="6">
<el-statistic title="库存商品总数" :value="selectedUserStats.stats.total_warehouse_quantity || 0" />
</el-col>
<el-col :span="6">
<el-statistic title="积分余额" :value="selectedUserStats.stats.score_balance || 0" />
</el-col>
<!-- <el-col :span="6">-->
<!-- <el-statistic title="最后活动时间" :value="formatTime(selectedUserStats.stats.last_activity_time)" />-->
<!-- </el-col>-->
</el-row>
</div>
<div v-else-if="userSearched && !selectedUserStats" class="no-user-found">
<el-empty description="未找到匹配的用户" />
</div>
</el-card>
</div>
<!-- 图表区域 -->
<div class="charts-section" v-if="authStore.isAdmin">
<el-row :gutter="20">
<el-col :span="12">
<el-card title="用户角色分布">
<template #header>
<div class="card-header">
<span>用户角色分布</span>
</div>
</template>
<div ref="roleChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card title="订单趋势">
<template #header>
<div class="card-header">
<span>最近7天订单趋势</span>
</div>
</template>
<div ref="orderTrendChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 最近活动 -->
<div v-if="authStore.isAdmin" class="recent-activities">
<el-card>
<template #header>
<div class="card-header">
<span>最近活动</span>
<el-button type="text" @click="refreshActivities">刷新</el-button>
</div>
</template>
<el-timeline>
<el-timeline-item
v-for="activity in recentActivities"
:key="activity.id"
:timestamp="activity.time"
placement="top"
>
<el-card>
<p>{{ activity.description }}</p>
</el-card>
</el-timeline-item>
</el-timeline>
<div v-if="recentActivities.length === 0" class="no-data">
<el-empty description="暂无活动记录" />
</div>
</el-card>
</div >
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useAuthStore } from '@/stores/auth'
import * as echarts from 'echarts'
import axios from 'axios'
const authStore = useAuthStore()
const stats = ref({})
const recentActivities = ref([])
const roleChartRef = ref(null)
const orderTrendChartRef = ref(null)
const userInfo=ref( null)
// 用户搜索相关
const userSearchQuery = ref('')
const dateRange = ref([])
const selectedUserStats = ref(null)
const userSearchLoading = ref(false)
const userSearched = ref(false)
let roleChart = null
let orderTrendChart = null
// 格式化金额
const formatAmount = (amount) => {
if (!amount) return '0.00'
return Number(amount).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
// 获取统计数据
const getStats = async () => {
try {
const [userStats, orderStats, productStats, orderTrendData] = await Promise.all([
axios.get('/admin/users/stats'),
axios.get('/admin/orders/purchase/stats'),
authStore.isAdmin ? axios.get('/admin/products/primary/stats') : Promise.resolve({ data: { data: {} } }),
axios.get('/admin/orders/purchase/trend')
])
// 处理订单趋势数据
const trendData = orderTrendData.data.data.trend || []
const days = trendData.map(item => item.date)
const orders = trendData.map(item => item.count)
stats.value = {
totalUsers: userStats.data.data.total_users || 0,
totalOrders: (orderStats.data.data.total_purchase_orders || 0) + (orderStats.data.data.total_sales_orders || 0),
totalProducts: productStats.data.data.total_primary_products || 0,
totalAmount: (orderStats.data.data.total_purchase_amount || 0) + (orderStats.data.data.total_sales_amount || 0),
roleStats: userStats.data.data.role_counts || [],
orderTrend: { days, orders }
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
// 初始化角色分布图表
const initRoleChart = () => {
if (!roleChartRef.value) return
roleChart = echarts.init(roleChartRef.value)
const roleNames = { 0: '管理员', 1: '代理商', 2: '普通用户' }
const data = (stats.value.roleStats || []).map(item => ({
name: roleNames[item.system_role] || '未知',
value: item.count
}))
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
bottom: 10,
left: 'center'
},
series: [
{
name: '用户角色',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '18',
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: data
}
]
}
roleChart.setOption(option)
}
// 初始化订单趋势图表
const initOrderTrendChart = () => {
if (!orderTrendChartRef.value) return
orderTrendChart = echarts.init(orderTrendChartRef.value)
const trendData = stats.value.orderTrend || { days: [], orders: [] }
const option = {
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: trendData.days
},
yAxis: {
type: 'value'
},
series: [
{
name: '订单数',
type: 'line',
stack: 'Total',
areaStyle: {},
data: trendData.orders
}
]
}
orderTrendChart.setOption(option)
}
// 获取最近活动
const getRecentActivities = async () => {
try {
const response = await axios.get('/admin/orders/purchase/activities')
if (response.data.success) {
// 格式化时间
recentActivities.value = (response.data.data.activities || []).map(activity => ({
id: activity.id,
description: activity.description,
time: new Date(activity.time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}))
}
} catch (error) {
console.error('获取最近活动失败:', error)
}
}
// 刷新活动记录
const refreshActivities = () => {
getRecentActivities()
}
// 获取用户角色名称
const getUserRoleName = (role) => {
const roleNames = { 0: '管理员', 1: '代理商', 2: '普通用户' }
return roleNames[role] || '未知'
}
// 格式化时间
const formatTime = (timeStr) => {
if (!timeStr) return '未知'
return new Date(timeStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 搜索用户统计
const searchUserStats = async () => {
if (!userSearchQuery.value.trim()) {
return
}
userSearchLoading.value = true
userSearched.value = true
selectedUserStats.value = null
try {
const params = {
query: userSearchQuery.value.trim()
}
// 添加时间范围参数
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const response = await axios.get('/admin/users/search-stats', {
params: params
})
if (response.data.success) {
selectedUserStats.value = response.data.data
} else {
selectedUserStats.value = null
}
} catch (error) {
console.error('搜索用户统计失败:', error)
selectedUserStats.value = null
} finally {
userSearchLoading.value = false
}
}
// 组件挂载后初始化
onMounted(async () => {
await getStats()
await getRecentActivities()
nextTick(() => {
initRoleChart()
initOrderTrendChart()
})
})
// 响应式图表
window.addEventListener('resize', () => {
if (roleChart) roleChart.resize()
if (orderTrendChart) orderTrendChart.resize()
})
</script>
<style scoped>
.dashboard {
padding: 0;
}
.dashboard-header {
margin-bottom: 24px;
}
.dashboard-header h2 {
margin: 0 0 8px 0;
color: #303133;
font-size: 24px;
font-weight: 600;
}
.dashboard-header p {
margin: 0;
color: #606266;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
transition: all 0.3s;
}
.stat-card:hover {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.stat-content {
display: flex;
align-items: center;
padding: 8px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
font-size: 24px;
color: white;
}
.user-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.order-icon {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.product-icon {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.money-icon {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.stat-info {
flex: 1;
}
.stat-number {
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #909399;
}
.charts-section {
margin-bottom: 24px;
}
.chart-container {
height: 300px;
width: 100%;
}
.recent-activities {
margin-bottom: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.no-data {
text-align: center;
padding: 40px 0;
}
.user-search-section {
margin-bottom: 24px;
}
.search-form {
margin-bottom: 20px;
}
.user-stats-result {
margin-top: 20px;
}
.no-user-found {
text-align: center;
padding: 40px 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.charts-section .el-col {
margin-bottom: 20px;
}
}
</style>
+553
View File
@@ -0,0 +1,553 @@
<template>
<div class="scores-page">
<div class="page-header">
<h2>积分管理</h2>
<el-button v-if="authStore.isAdmin" type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon>
添加积分记录
</el-button>
</div>
<!-- 搜索筛选 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" :inline="true" class="search-form">
<el-form-item label="用户ID">
<el-input
v-model="searchForm.user_id"
placeholder="请输入用户ID"
clearable
style="width: 150px"
/>
</el-form-item>
<el-form-item label="手机号">
<el-input
v-model="searchForm.phone"
placeholder="请输入手机号"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="变化类型">
<el-select
v-model="searchForm.change_type"
placeholder="请选择类型"
clearable
style="width: 150px"
>
<el-option label="增加" value="positive" />
<el-option label="减少" value="negative" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getScoreRecords">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 积分记录列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="scoreRecords"
stripe
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
<el-table-column label="用户信息" width="160">
<template #default="{ row }">
<div>{{ row.user?.customer_name || row.user?.phone }}</div>
<div class="text-xs text-gray-500">ID: {{ row.user_id }}</div>
<div class="text-xs text-gray-500">{{ row.user?.phone }}</div>
</template>
</el-table-column>
<el-table-column prop="change_number" label="积分变化" width="120" sortable="custom">
<template #default="{ row }">
<span :class="getChangeNumberClass(row.change_number)">
{{ row.change_number > 0 ? '+' : '' }}{{ row.change_number }}
</span>
</template>
</el-table-column>
<el-table-column label="当前积分" width="100">
<template #default="{ row }">
<span class="font-semibold">{{ row.user?.current_points || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="160" sortable="custom">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="text" size="small" @click="showScoreDetail(row)">
查看
</el-button>
<!-- <el-button-->
<!-- v-if="authStore.isAdmin"-->
<!-- type="text"-->
<!-- size="small"-->
<!-- @click="showEditDialog(row)"-->
<!-- >-->
<!-- 编辑-->
<!-- </el-button>-->
<el-button
v-if="authStore.isAdmin"
type="text"
size="small"
class="danger-text"
@click="deleteScoreRecord(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getScoreRecords"
@current-change="getScoreRecords"
/>
</div>
</el-card>
<!-- 创建/编辑积分记录对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '添加积分记录' : '编辑积分记录'"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="scoreFormRef"
:model="scoreForm"
:rules="scoreRules"
label-width="100px"
>
<el-form-item label="用户" prop="user_id">
<el-select
v-model="scoreForm.user_id"
placeholder="请选择用户"
filterable
remote
reserve-keyword
:remote-method="searchUsers"
:loading="userSearchLoading"
style="width: 100%"
>
<el-option
v-for="user in userOptions"
:key="user.id"
:label="`${user.customer_name || user.real_name} (${user.phone})`"
:value="user.id"
/>
</el-select>
</el-form-item>
<el-form-item label="积分变化" prop="change_number">
<el-input-number
v-model="scoreForm.change_number"
:min="-999999"
:max="999999"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="说明" prop="note">
<el-input
v-model="scoreForm.note"
type="textarea"
:rows="4"
placeholder="请输入变化说明"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitScore">
确定
</el-button>
</span>
</template>
</el-dialog>
<!-- 积分记录详情对话框 -->
<el-dialog
v-model="detailVisible"
title="积分记录详情"
width="600px"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="记录ID">{{ currentRecord.id }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentRecord.user_id }}</el-descriptions-item>
<el-descriptions-item label="用户姓名">{{ currentRecord.user?.customer_name || currentRecord.user?.real_name }}</el-descriptions-item>
<el-descriptions-item label="用户手机">{{ currentRecord.user?.phone }}</el-descriptions-item>
<el-descriptions-item label="积分变化">
<span :class="getChangeNumberClass(currentRecord.change_number)">
{{ currentRecord.change_number > 0 ? '+' : '' }}{{ currentRecord.change_number }}
</span>
</el-descriptions-item>
<el-descriptions-item label="当前积分">{{ currentRecord.user?.current_points || 0 }}</el-descriptions-item>
<el-descriptions-item label="说明" :span="2">{{ currentRecord.note }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentRecord.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(currentRecord.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import axios from 'axios'
import dayjs from 'dayjs'
const authStore = useAuthStore()
// 响应式数据
const loading = ref(false)
const scoreRecords = ref([])
const dialogVisible = ref(false)
const detailVisible = ref(false)
const dialogMode = ref('create')
const submitting = ref(false)
const scoreFormRef = ref()
const userSearchLoading = ref(false)
const userOptions = ref([])
// 分页信息
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
user_id: '',
phone: '',
change_type: ''
})
// 积分记录表单
const scoreForm = reactive({
user_id: '',
change_number: 0,
note: ''
})
// 当前查看的记录
const currentRecord = ref({})
// 表单验证规则
const scoreRules = {
user_id: [
{ required: true, message: '请选择用户', trigger: 'change' }
],
change_number: [
{ required: true, message: '请输入积分变化', trigger: 'blur' },
{ type: 'number', message: '积分变化必须为数字' }
],
note: [
{ required: true, message: '请输入变化说明', trigger: 'blur' },
{ max: 200, message: '说明不能超过200个字符', trigger: 'blur' }
]
}
// 获取积分记录列表
const getScoreRecords = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await axios.get('/admin/scores', { params })
if (response.data.success) {
scoreRecords.value = response.data.data.list || []
pagination.total = response.data.data.total || 0
}
} catch (error) {
console.error('获取积分记录失败:', error)
} finally {
loading.value = false
}
}
// 重置搜索
const resetSearch = () => {
Object.assign(searchForm, {
user_id: '',
phone: '',
change_type: ''
})
pagination.page = 1
getScoreRecords()
}
// 处理排序
const handleSortChange = ({ prop, order }) => {
// 实现排序逻辑
console.log('排序:', prop, order)
}
// 获取积分变化样式
const getChangeNumberClass = (changeNumber) => {
if (changeNumber > 0) {
return 'text-green-500 font-semibold'
} else if (changeNumber < 0) {
return 'text-red-500 font-semibold'
}
return 'text-gray-500'
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 搜索用户
const searchUsers = async (query) => {
if (!query) {
userOptions.value = []
return
}
userSearchLoading.value = true
try {
const response = await axios.get('/admin/users', {
params: {
phone: query,
page: 1,
page_size: 20
}
})
if (response.data.success) {
userOptions.value = response.data.data.list || []
}
} catch (error) {
console.error('搜索用户失败:', error)
} finally {
userSearchLoading.value = false
}
}
// 显示创建对话框
const showCreateDialog = () => {
dialogMode.value = 'create'
resetScoreForm()
dialogVisible.value = true
}
// 显示编辑对话框
const showEditDialog = (record) => {
dialogMode.value = 'edit'
Object.assign(scoreForm, {
user_id: record.user_id,
change_number: record.change_number,
note: record.note
})
// 预填充用户选项
userOptions.value = [record.user]
dialogVisible.value = true
}
// 显示积分记录详情
const showScoreDetail = (record) => {
currentRecord.value = record
detailVisible.value = true
}
// 重置积分记录表单
const resetScoreForm = () => {
Object.assign(scoreForm, {
user_id: '',
change_number: 0,
note: ''
})
userOptions.value = []
if (scoreFormRef.value) {
scoreFormRef.value.resetFields()
}
}
// 提交积分记录表单
const submitScore = async () => {
if (!scoreFormRef.value) return
const valid = await scoreFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (dialogMode.value === 'create') {
await axios.post('/admin/scores', scoreForm)
ElMessage.success('积分记录创建成功')
} else {
await axios.put(`/admin/scores/${currentRecord.value.id}`, scoreForm)
ElMessage.success('积分记录更新成功')
}
dialogVisible.value = false
getScoreRecords()
} catch (error) {
console.error('提交积分记录失败:', error)
} finally {
submitting.value = false
}
}
// 删除积分记录
const deleteScoreRecord = async (record) => {
try {
await ElMessageBox.confirm(
`确定要删除这条积分记录吗?删除后用户积分将会恢复。`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await axios.delete(`/admin/scores/${record.id}`)
ElMessage.success('积分记录删除成功')
getScoreRecords()
} catch (error) {
if (error !== 'cancel') {
console.error('删除积分记录失败:', error)
}
}
}
// 组件挂载后获取数据
onMounted(() => {
getScoreRecords()
})
</script>
<style scoped>
.scores-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
margin-bottom: -18px;
}
.table-card {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.text-xs {
font-size: 12px;
}
.text-gray-500 {
color: #6b7280;
}
.text-green-500 {
color: #10b981;
}
.text-red-500 {
color: #ef4444;
}
.font-semibold {
font-weight: 600;
}
.danger-text {
color: #f56c6c;
}
.danger-text:hover {
color: #f78989;
}
.dialog-footer {
text-align: right;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form .el-form-item {
width: 100%;
margin-right: 0;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
</style>
+692
View File
@@ -0,0 +1,692 @@
<template>
<div class="system-configs-page">
<div class="page-header">
<h2>系统配置管理</h2>
<el-button type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon>
添加配置
</el-button>
</div>
<!-- 搜索筛选 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" :inline="true" class="search-form">
<el-form-item label="配置键">
<el-input
v-model="searchForm.config_key"
placeholder="请输入配置键"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="配置类型">
<el-select
v-model="searchForm.config_type"
placeholder="请选择类型"
clearable
style="width: 150px"
>
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔值" value="boolean" />
<el-option label="JSON" value="json" />
</el-select>
</el-form-item>
<el-form-item label="系统配置">
<el-select
v-model="searchForm.is_system"
placeholder="请选择"
clearable
style="width: 120px"
>
<el-option label="是" value="1" />
<el-option label="否" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getConfigs">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 配置列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="configs"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="config_key" label="配置键" width="200" />
<el-table-column prop="config_name" label="配置名称" width="150" />
<el-table-column prop="config_value" label="配置值" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<div class="config-value">
<span v-if="row.config_type === 'boolean'">
<el-tag :type="row.config_value === 'true' ? 'success' : 'danger'" size="small">
{{ row.config_value === 'true' ? '是' : '否' }}
</el-tag>
</span>
<span v-else-if="row.config_type === 'json'" class="json-value">
{{ formatJsonValue(row.config_value) }}
</span>
<span v-else>{{ row.config_value }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="config_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="getTypeTagColor(row.config_type)" size="small">
{{ getTypeName(row.config_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_system" label="系统配置" width="100">
<template #default="{ row }">
<el-tag :type="row.is_system === 1 ? 'warning' : 'info'" size="small">
{{ row.is_system === 1 ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="config_description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="text" size="small" @click="showConfigDetail(row)">
查看
</el-button>
<el-button type="text" size="small" @click="showEditDialog(row)">
编辑
</el-button>
<el-button
v-if="row.is_system !== 1"
type="text"
size="small"
class="danger-text"
@click="deleteConfig(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getConfigs"
@current-change="getConfigs"
/>
</div>
</el-card>
<!-- 创建/编辑配置对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '添加配置' : '编辑配置'"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="configFormRef"
:model="configForm"
:rules="configRules"
label-width="100px"
>
<el-form-item label="配置键" prop="config_key">
<el-input
v-model="configForm.config_key"
placeholder="请输入配置键(英文字母、数字、下划线)"
:disabled="dialogMode === 'edit' && configForm.is_system === 1"
maxlength="100"
/>
</el-form-item>
<el-form-item label="配置名称" prop="config_name">
<el-input
v-model="configForm.config_name"
placeholder="请输入配置名称"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="配置类型" prop="config_type">
<el-select
v-model="configForm.config_type"
placeholder="请选择配置类型"
style="width: 100%"
@change="onTypeChange"
>
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔值" value="boolean" />
<el-option label="JSON" value="json" />
</el-select>
</el-form-item>
<el-form-item label="配置值" prop="config_value">
<!-- 字符串类型 -->
<el-input
v-if="configForm.config_type === 'string'"
v-model="configForm.config_value"
placeholder="请输入配置值"
maxlength="1000"
/>
<!-- 数字类型 -->
<el-input-number
v-else-if="configForm.config_type === 'number'"
v-model="configForm.config_value"
:precision="6"
controls-position="right"
style="width: 100%"
/>
<!-- 布尔值类型 -->
<el-radio-group
v-else-if="configForm.config_type === 'boolean'"
v-model="configForm.config_value"
>
<el-radio label="true"></el-radio>
<el-radio label="false"></el-radio>
</el-radio-group>
<!-- JSON类型 -->
<el-input
v-else-if="configForm.config_type === 'json'"
v-model="configForm.config_value"
type="textarea"
:rows="6"
placeholder="请输入有效的JSON格式"
/>
<!-- 默认 -->
<el-input
v-else
v-model="configForm.config_value"
placeholder="请输入配置值"
/>
</el-form-item>
<el-form-item label="配置描述">
<el-input
v-model="configForm.config_description"
type="textarea"
:rows="3"
placeholder="请输入配置描述"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitConfig">
确定
</el-button>
</span>
</template>
</el-dialog>
<!-- 配置详情对话框 -->
<el-dialog
v-model="detailVisible"
title="配置详情"
width="600px"
>
<el-descriptions :column="1" border>
<el-descriptions-item label="配置ID">{{ currentConfig.id }}</el-descriptions-item>
<el-descriptions-item label="配置键">{{ currentConfig.config_key }}</el-descriptions-item>
<el-descriptions-item label="配置名称">{{ currentConfig.config_name }}</el-descriptions-item>
<el-descriptions-item label="配置类型">
<el-tag :type="getTypeTagColor(currentConfig.config_type)">
{{ getTypeName(currentConfig.config_type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="系统配置">
<el-tag :type="currentConfig.is_system === 1 ? 'warning' : 'info'">
{{ currentConfig.is_system === 1 ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="配置值">
<div class="config-value-detail">
<span v-if="currentConfig.config_type === 'boolean'">
<el-tag :type="currentConfig.config_value === 'true' ? 'success' : 'danger'">
{{ currentConfig.config_value === 'true' ? '是' : '否' }}
</el-tag>
</span>
<pre v-else-if="currentConfig.config_type === 'json'" class="json-preview">{{ formatJson(currentConfig.config_value) }}</pre>
<span v-else>{{ currentConfig.config_value }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="配置描述">{{ currentConfig.config_description || '无' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentConfig.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(currentConfig.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
import dayjs from 'dayjs'
// 响应式数据
const loading = ref(false)
const configs = ref([])
const dialogVisible = ref(false)
const detailVisible = ref(false)
const dialogMode = ref('create')
const submitting = ref(false)
const configFormRef = ref()
// 分页信息
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
config_key: '',
config_type: '',
is_system: ''
})
// 配置表单
const configForm = reactive({
config_key: '',
config_name: '',
config_value: '',
config_type: 'string',
config_description: '',
is_system: 0
})
// 当前查看的配置
const currentConfig = ref({})
// 表单验证规则
const configRules = {
config_key: [
{ required: true, message: '请输入配置键', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '配置键只能包含字母、数字和下划线', trigger: 'blur' },
{ max: 100, message: '配置键不能超过100个字符', trigger: 'blur' }
],
config_name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' },
{ max: 200, message: '配置名称不能超过200个字符', trigger: 'blur' }
],
config_type: [
{ required: true, message: '请选择配置类型', trigger: 'change' }
],
config_value: [
{ required: true, message: '请输入配置值', trigger: 'blur' },
{ validator: validateConfigValue, trigger: 'blur' }
]
}
// 配置值验证器
function validateConfigValue(rule, value, callback) {
if (!value && value !== 0 && value !== false) {
callback(new Error('请输入配置值'))
return
}
const type = configForm.config_type
if (type === 'number') {
if (isNaN(Number(value))) {
callback(new Error('请输入有效的数字'))
return
}
} else if (type === 'boolean') {
if (value !== 'true' && value !== 'false') {
callback(new Error('请选择布尔值'))
return
}
} else if (type === 'json') {
try {
JSON.parse(value)
} catch (error) {
callback(new Error('请输入有效的JSON格式'))
return
}
}
callback()
}
// 获取配置列表
const getConfigs = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await axios.get('/admin/configs', { params })
if (response.data.success) {
configs.value = response.data.data.list || []
pagination.total = response.data.data.total || 0
}
} catch (error) {
console.error('获取配置列表失败:', error)
} finally {
loading.value = false
}
}
// 重置搜索
const resetSearch = () => {
Object.assign(searchForm, {
config_key: '',
config_type: '',
is_system: ''
})
pagination.page = 1
getConfigs()
}
// 获取类型名称
const getTypeName = (type) => {
const typeMap = {
string: '字符串',
number: '数字',
boolean: '布尔值',
json: 'JSON'
}
return typeMap[type] || type
}
// 获取类型标签颜色
const getTypeTagColor = (type) => {
const colorMap = {
string: '',
number: 'success',
boolean: 'warning',
json: 'danger'
}
return colorMap[type] || ''
}
// 格式化JSON值(简化显示)
const formatJsonValue = (value) => {
try {
const obj = JSON.parse(value)
return typeof obj === 'object' ? `{${Object.keys(obj).length} 项}` : value
} catch {
return value
}
}
// 格式化JSON(详细显示)
const formatJson = (value) => {
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch {
return value
}
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 类型变化处理
const onTypeChange = (type) => {
if (type === 'boolean') {
configForm.config_value = 'true'
} else if (type === 'number') {
configForm.config_value = 0
} else if (type === 'json') {
configForm.config_value = '{}'
} else {
configForm.config_value = ''
}
}
// 显示创建对话框
const showCreateDialog = () => {
dialogMode.value = 'create'
resetConfigForm()
dialogVisible.value = true
}
// 显示编辑对话框
const showEditDialog = (config) => {
dialogMode.value = 'edit'
Object.assign(configForm, {
id: config.id,
config_key: config.config_key,
config_name: config.config_name,
config_value: config.config_value,
config_type: config.config_type,
config_description: config.config_description,
is_system: config.is_system
})
dialogVisible.value = true
}
// 显示配置详情
const showConfigDetail = (config) => {
currentConfig.value = config
detailVisible.value = true
}
// 重置配置表单
const resetConfigForm = () => {
Object.assign(configForm, {
config_key: '',
config_name: '',
config_value: '',
config_type: 'string',
config_description: '',
is_system: 0
})
if (configFormRef.value) {
configFormRef.value.resetFields()
}
}
// 提交配置表单
const submitConfig = async () => {
if (!configFormRef.value) return
const valid = await configFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
// 根据类型转换配置值
let submitData = { ...configForm }
if (configForm.config_type === 'number') {
submitData.config_value = String(Number(configForm.config_value))
}
if (dialogMode.value === 'create') {
await axios.post('/admin/configs', submitData)
ElMessage.success('配置创建成功')
} else {
const { id, ...updateData } = submitData
await axios.put(`/admin/configs/${id}`, updateData)
ElMessage.success('配置更新成功')
}
dialogVisible.value = false
getConfigs()
} catch (error) {
console.error('提交配置失败:', error)
} finally {
submitting.value = false
}
}
// 删除配置
const deleteConfig = async (config) => {
try {
await ElMessageBox.confirm(
`确定要删除配置 "${config.config_name}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await axios.delete(`/admin/configs/${config.id}`)
ElMessage.success('配置删除成功')
getConfigs()
} catch (error) {
if (error !== 'cancel') {
console.error('删除配置失败:', error)
}
}
}
// 组件挂载后获取数据
onMounted(() => {
getConfigs()
})
</script>
<style scoped>
.system-configs-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
margin-bottom: -18px;
}
.table-card {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.config-value {
max-width: 200px;
word-break: break-all;
}
.json-value {
font-family: 'Courier New', monospace;
background-color: #f5f7fa;
padding: 2px 4px;
border-radius: 3px;
font-size: 12px;
}
.config-value-detail {
max-width: 100%;
word-break: break-all;
}
.json-preview {
background-color: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
.danger-text {
color: #f56c6c;
}
.danger-text:hover {
color: #f78989;
}
.dialog-footer {
text-align: right;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form .el-form-item {
width: 100%;
margin-right: 0;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
</style>
+516
View File
@@ -0,0 +1,516 @@
<template>
<div class="user-warehouses-page">
<div class="page-header">
<h2>用户库存管理</h2>
<el-button type="primary" @click="refreshData">
<el-icon><Refresh /></el-icon>
刷新数据
</el-button>
</div>
<!-- 搜索筛选 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" :inline="true" class="search-form">
<el-form-item label="搜索用户">
<el-input
v-model="searchForm.search"
placeholder="请输入手机号或用户姓名"
clearable
style="width: 250px"
@keyup.enter="getUserWarehouses"
>
<template #prepend>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<!-- <el-form-item label="商品类型">-->
<!-- <el-select-->
<!-- v-model="searchForm.product_type"-->
<!-- placeholder="请选择商品类型"-->
<!-- clearable-->
<!-- style="width: 150px"-->
<!-- >-->
<!-- <el-option label="一级商品" :value="1" />-->
<!-- <el-option label="二级商品" :value="2" />-->
<!-- </el-select>-->
<!-- </el-form-item>-->
<el-form-item label="库存状态">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<el-option label="库存中" :value="1" />
<el-option label="已出售" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getUserWarehouses" :loading="loading">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 用户库存统计卡片 -->
<div class="stats-cards">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stat-card">
<el-statistic title="有库存用户数" :value="totalUsers || 0" />
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<el-statistic title="库存商品总数" :value="totalQuantity || 0" />
</el-card>
</el-col>
<!-- <el-col :span="6">-->
<!-- <el-card class="stat-card">-->
<!-- <el-statistic title="库存总价值" :value="totalValue || 0" prefix="¥" :precision="2" />-->
<!-- </el-card>-->
<!-- </el-col>-->
<!-- <el-col :span="6">-->
<!-- <el-card class="stat-card">-->
<!-- <el-statistic title="已出售数量" :value="soldCount || 0" />-->
<!-- </el-card>-->
<!-- </el-col>-->
</el-row>
</div>
<!-- 用户库存列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="userWarehouses"
stripe
style="width: 100%"
>
<el-table-column prop="user_id" label="用户ID" width="80" />
<el-table-column label="用户信息" min-width="200">
<template #default="{ row }">
<div class="user-info">
<div class="user-name">{{ row.customer_name || row.real_name || '未知用户' }}</div>
<div class="user-phone">{{ row.phone }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="product_type" label="商品类型" width="100">
<template #default="{ row }">
<el-tag :type="row.product_type === 1 ? 'primary' : 'success'" size="small">
{{ row.product_type === 1 ? '一级商品' : '二级商品' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="product_name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="total_quantity" label="库存数量" width="100" sortable>
<template #default="{ row }">
<span :class="{ 'low-stock': row.total_quantity < 10 }">{{ row.total_quantity }}</span>
</template>
</el-table-column>
<el-table-column prop="total_value" label="库存价值" width="120" sortable>
<template #default="{ row }">
¥{{ formatAmount(row.total_value) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '库存中' : '已出售' }}
</el-tag>
</template>
</el-table-column>
<!-- <el-table-column label="统计信息" width="200">-->
<!-- <template #default="{ row }">-->
<!-- <div class="stats-info">-->
<!-- <div>一级: {{ row.primary_product_count }}</div>-->
<!-- <div>二级: {{ row.secondary_product_count }}</div>-->
<!-- </div>-->
<!-- </template>-->
<!-- </el-table-column>-->
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="text" size="small" @click="viewUserDetail(row)">
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getUserWarehouses"
@current-change="getUserWarehouses"
/>
</div>
</el-card>
<!-- 用户库存详情对话框 -->
<el-dialog
v-model="detailVisible"
:title="`用户库存详情 - ${currentUser?.customer_name || currentUser?.real_name || ''}`"
width="800px"
>
<div v-if="currentUserWarehouses.length > 0" class="warehouse-detail">
<el-descriptions :column="2" border class="mb-4">
<el-descriptions-item label="客户姓名">{{ currentUser.customer_name }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ currentUser.real_name }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ currentUser.phone }}</el-descriptions-item>
<el-descriptions-item label="库存总价值">
<span class="text-primary">¥{{ formatAmount(currentUserTotalValue) }}</span>
</el-descriptions-item>
</el-descriptions>
<el-table :data="currentUserWarehouses" stripe>
<el-table-column prop="product_type" label="商品类型" width="100">
<template #default="{ row }">
<el-tag :type="row.product_type === 1 ? 'primary' : 'success'" size="small">
{{ row.product_type === 1 ? '一级商品' : '二级商品' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="product_name" label="商品名称" min-width="200" />
<el-table-column prop="total_quantity" label="数量" width="80" />
<el-table-column prop="total_value" label="价值" width="120">
<template #default="{ row }">
¥{{ formatAmount(row.total_value) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '库存中' : '已出售' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div v-else class="no-data">
<el-empty description="暂无库存数据" />
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import axios from 'axios'
const authStore = useAuthStore()
// 响应式数据
const loading = ref(false)
const userWarehouses = ref([])
const detailVisible = ref(false)
const currentUser = ref({})
const currentUserWarehouses = ref([])
// 分页信息
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
search: '',
product_type: '',
status: ''
})
// 统计信息
const totalUsers = ref(0)
const totalQuantity = ref(0)
const totalValue = ref(0)
const soldCount = ref(0)
// 计算当前用户库存总价值
const currentUserTotalValue = computed(() => {
return currentUserWarehouses.value.reduce((sum, item) => {
return sum + (item.total_value || 0)
}, 0)
})
// 格式化金额
const formatAmount = (amount) => {
if (!amount) return '0.00'
return Number(amount).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
// 获取用户库存列表
const getUserWarehouses = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await axios.get('/admin/warehouse/users', { params })
if (response.data.success) {
const data = response.data.data
// 如果后端返回的数据超过pageSize,只显示pageSize条
const list = data.list || []
if (list.length > pagination.pageSize) {
userWarehouses.value = list.slice(0, pagination.pageSize)
} else {
userWarehouses.value = list
}
pagination.total = data.total || 0
// 计算统计数据
calculateStats()
}
} catch (error) {
console.error('获取用户库存列表失败:', error)
ElMessage.error('获取用户库存列表失败')
} finally {
loading.value = false
}
}
// 计算统计数据
const calculateStats = () => {
const warehouses = userWarehouses.value
// 去重计算用户数(根据 user_id)
const uniqueUsers = new Set(warehouses.map(w => w.user_id))
totalUsers.value = uniqueUsers.size
// 计算总数量(只统计库存中的)
const stockItems = warehouses.filter(w => w.status === 1)
totalQuantity.value = stockItems.reduce((sum, w) => sum + (w.total_quantity || 0), 0)
// 计算总价值(只统计库存中的)
totalValue.value = stockItems.reduce((sum, w) => sum + (w.total_value || 0), 0)
// 计算已出售数量
soldCount.value = warehouses
.filter(w => w.status === 0)
.reduce((sum, w) => sum + (w.total_quantity || 0), 0)
}
// 重置搜索
const resetSearch = () => {
Object.assign(searchForm, {
search: '',
product_type: '',
status: ''
})
pagination.page = 1
getUserWarehouses()
}
// 刷新数据
const refreshData = () => {
getUserWarehouses()
ElMessage.success('数据已刷新')
}
// 查看用户详情
const viewUserDetail = async (warehouse) => {
currentUser.value = {
user_id: warehouse.user_id,
customer_name: warehouse.customer_name,
real_name: warehouse.real_name,
phone: warehouse.phone
}
// 获取该用户的所有库存(不区分商品类型和状态)
try {
const params = {
search: warehouse.phone,
page: 1,
page_size: 1000
}
// 清除筛选条件,获取该用户的所有库存
const searchKey = searchForm.search
const productType = searchForm.product_type
const status = searchForm.status
searchForm.search = warehouse.phone
searchForm.product_type = ''
searchForm.status = ''
const response = await axios.get('/admin/warehouse/users', {
params: {
search: warehouse.phone,
page: 1,
page_size: 1000
}
})
if (response.data.success) {
currentUserWarehouses.value = response.data.data.list || []
}
// 恢复原有的搜索条件
searchForm.search = searchKey
searchForm.product_type = productType
searchForm.status = status
detailVisible.value = true
} catch (error) {
console.error('获取用户库存详情失败:', error)
ElMessage.error('获取用户库存详情失败')
}
}
// 组件挂载后获取数据
onMounted(() => {
getUserWarehouses()
})
</script>
<style scoped>
.user-warehouses-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
margin-bottom: -18px;
}
.stats-cards {
margin-bottom: 20px;
}
.stat-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
transition: all 0.3s;
}
.stat-card:hover {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.table-card {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.user-info {
line-height: 1.5;
}
.user-name {
font-weight: 500;
color: #303133;
}
.user-phone {
font-size: 12px;
color: #909399;
}
.low-stock {
color: #f56c6c;
font-weight: bold;
}
.stats-info {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #606266;
}
.text-primary {
color: #409eff;
font-weight: 600;
}
.mb-4 {
margin-bottom: 16px;
}
.no-data {
text-align: center;
padding: 40px 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form .el-form-item {
width: 100%;
margin-right: 0;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.stats-cards .el-col {
margin-bottom: 16px;
}
}
</style>
+950
View File
@@ -0,0 +1,950 @@
<template>
<div class="users-page">
<div class="page-header">
<h2>用户管理</h2>
<el-button v-if="authStore.isAdmin" type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon>
添加用户
</el-button>
</div>
<!-- 搜索筛选 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" :inline="true" class="search-form">
<el-form-item label="手机号">
<el-input
v-model="searchForm.phone"
placeholder="请输入手机号"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="真实姓名">
<el-input
v-model="searchForm.real_name"
placeholder="请输入真实姓名"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="用户角色">
<el-select
v-model="searchForm.system_role"
placeholder="请选择角色"
clearable
style="width: 150px"
>
<!-- <el-option label="管理员" value="0" />-->
<el-option label="代理商" value="1" />
<el-option label="普通用户" value="2" />
</el-select>
</el-form-item>
<el-form-item label="身份码">
<el-input
v-model="searchForm.identity_code"
placeholder="请输入身份码"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getUsers">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 用户列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="users"
stripe
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="customer_name" label="客户姓名" width="120" />
<el-table-column prop="real_name" label="真实姓名" width="120" />
<el-table-column prop="system_role" label="角色" width="100" sortable="custom">
<template #default="{ row }">
<el-tag
:type="getRoleTagType(row.system_role)"
size="small"
>
{{ getRoleName(row.system_role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="current_points" label="当前积分" width="100" sortable="custom" />
<el-table-column prop="identity_code" label="身份码" width="120" />
<el-table-column prop="company_name" label="公司名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="referrer_identity_code" label="推荐人身份码" width="140" />
<el-table-column prop="status" label="状态" width="80" sortable="custom">
<template #default="{ row }">
<el-tag
:type="row.status === 1 ? 'success' : 'danger'"
size="small"
>
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="expiry_date" label="过期时间" width="160" sortable="custom">
<template #default="{ row }">
<span v-if="row.expiry_date" :class="{ 'expired-text': isExpired(row.expiry_date) }">
{{ formatDate(row.expiry_date) }}
</span>
<el-tag v-else type="success" size="small">永不过期</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="text" size="small" @click="showUserDetail(row)">
查看
</el-button>
<el-button
v-if="authStore.isAdmin"
type="text"
size="small"
@click="showEditDialog(row)"
>
编辑
</el-button>
<el-button
v-if="authStore.isAdmin"
type="text"
size="small"
@click="showPaymentDialog(row)"
>
付款码
</el-button>
<el-button
v-if="authStore.isAdmin && row.system_role !== 0"
type="text"
size="small"
@click="showExpiryDialog(row)"
>
设置过期
</el-button>
<el-button
v-if="authStore.isAdmin && row.system_role !== 0"
type="text"
size="small"
class="danger-text"
@click="deleteUser(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getUsers"
@current-change="getUsers"
/>
</div>
</el-card>
<!-- 创建/编辑用户对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '添加用户' : '编辑用户'"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="userFormRef"
:model="userForm"
:rules="userRules"
label-width="100px"
>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="userForm.phone"
placeholder="请输入手机号"
:disabled="dialogMode === 'edit'"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="userForm.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="用户角色" prop="system_role">
<el-select v-model="userForm.system_role" placeholder="请选择角色">
<!-- <el-option label="管理员" :value="0" />-->
<el-option label="代理商" :value="1" />
<el-option label="普通用户" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="客户姓名" prop="customer_name">
<el-input v-model="userForm.customer_name" placeholder="请输入客户姓名" />
</el-form-item>
<el-form-item label="真实姓名" prop="real_name">
<el-input v-model="userForm.real_name" placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="公司名称">
<el-input v-model="userForm.company_name" placeholder="请输入公司名称" />
</el-form-item>
<el-form-item label="推荐人身份码">
<el-input v-model="userForm.referrer_identity_code" placeholder="请输入推荐人身份码" />
</el-form-item>
<el-form-item label="个人介绍">
<el-input
v-model="userForm.personal_intro"
type="textarea"
:rows="3"
placeholder="请输入个人介绍"
/>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="userForm.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitUser">
确定
</el-button>
</span>
</template>
</el-dialog>
<!-- 用户详情对话框 -->
<el-dialog
v-model="detailVisible"
title="用户详情"
width="600px"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID">{{ currentUser.id }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ currentUser.phone }}</el-descriptions-item>
<el-descriptions-item label="用户角色">
<el-tag :type="getRoleTagType(currentUser.system_role)">
{{ getRoleName(currentUser.system_role) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentUser.status === 1 ? 'success' : 'danger'">
{{ currentUser.status === 1 ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="客户姓名">{{ currentUser.customer_name }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ currentUser.real_name }}</el-descriptions-item>
<el-descriptions-item label="当前积分">{{ currentUser.current_points }}</el-descriptions-item>
<el-descriptions-item label="身份码">{{ currentUser.identity_code }}</el-descriptions-item>
<el-descriptions-item label="公司名称" :span="2">{{ currentUser.company_name || '未填写' }}</el-descriptions-item>
<el-descriptions-item label="推荐人身份码" :span="2">{{ currentUser.referrer_identity_code || '无' }}</el-descriptions-item>
<el-descriptions-item label="个人介绍" :span="2">{{ currentUser.personal_intro || '未填写' }}</el-descriptions-item>
<el-descriptions-item label="过期时间" :span="2">
<span v-if="currentUser.expiry_date" :class="{ 'expired-text': isExpired(currentUser.expiry_date) }">
{{ formatDate(currentUser.expiry_date) }}
</span>
<el-tag v-else type="success" size="small">永不过期</el-tag>
</el-descriptions-item>
<el-descriptions-item label="营业执照" :span="2">
<el-image
:src="currentUser.business_license_image"
fit="cover"
:preview-src-list="[currentUser.business_license_image]"
style="width: 248px"
/>
</el-descriptions-item>
<el-descriptions-item label="身份证正面" :span="2">
<el-image
:src="currentUser.id_card_front_image"
:preview-src-list="[currentUser.id_card_front_image]"
fit="cover"
style="width: 248px"
/>
</el-descriptions-item>
<el-descriptions-item label="身份证反面" :span="2">
<el-image
:src="currentUser.id_card_back_image"
:preview-src-list="[currentUser.id_card_back_image]"
fit="cover"
style="width: 248px"
/>
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{ formatDate(currentUser.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ formatDate(currentUser.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 设置用户过期时间对话框 -->
<el-dialog
v-model="expiryVisible"
title="设置用户过期时间"
width="400px"
:close-on-click-modal="false"
>
<el-form
ref="expiryFormRef"
:model="expiryForm"
label-width="80px"
>
<el-form-item label="用户">
<span>{{ expiryUser.customer_name || expiryUser.real_name || expiryUser.phone }}</span>
</el-form-item>
<el-form-item label="过期设置">
<el-radio-group v-model="expiryForm.type">
<el-radio label="never">永不过期</el-radio>
<el-radio label="custom">设置过期时间</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="expiryForm.type === 'custom'" label="过期时间">
<el-date-picker
v-model="expiryForm.expiryDate"
type="datetime"
placeholder="选择过期时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ssZ"
:disabled-date="disabledDate"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="expiryVisible = false">取消</el-button>
<el-button type="primary" :loading="expirySubmitting" @click="submitExpiry">
确定
</el-button>
</span>
</template>
</el-dialog>
<!-- 用户付款码管理对话框 -->
<el-dialog
v-model="paymentVisible"
title="用户付款码管理"
width="600px"
:close-on-click-modal="false"
>
<div class="payment-info">
<p><strong>用户</strong>{{ paymentUser.customer_name || paymentUser.real_name || paymentUser.phone }}</p>
<p><strong>手机号</strong>{{ paymentUser.phone }}</p>
</div>
<el-form
ref="paymentFormRef"
:model="paymentForm"
label-width="120px"
>
<el-form-item label="主收款码">
<ImageUpload
v-model="paymentForm.main_payment_qr_image"
:limit="1"
:multiple="false"
accept="image/*"
list-type="picture-card"
:preview-src-list="paymentForm.main_payment_qr_image ? [getFullImageUrl(paymentForm.main_payment_qr_image)] : []"
/>
<div class="upload-tip">支持jpgpng格式建议尺寸200x200px</div>
</el-form-item>
<el-form-item label="副收款码">
<ImageUpload
v-model="paymentForm.sub_payment_qr_image"
:limit="1"
:multiple="false"
accept="image/*"
list-type="picture-card"
:preview-src-list="paymentForm.sub_payment_qr_image ? [getFullImageUrl(paymentForm.sub_payment_qr_image)] : []"
/>
<div class="upload-tip">支持jpgpng格式建议尺寸200x200px</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="paymentVisible = false">取消</el-button>
<el-button type="primary" :loading="paymentSubmitting" @click="updatePaymentInfo">
保存
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import ImageUpload from '@/components/ImageUpload.vue'
import axios from 'axios'
import dayjs from 'dayjs'
const authStore = useAuthStore()
// 响应式数据
const loading = ref(false)
const users = ref([])
const dialogVisible = ref(false)
const detailVisible = ref(false)
const expiryVisible = ref(false)
const paymentVisible = ref(false)
const dialogMode = ref('create')
const submitting = ref(false)
const expirySubmitting = ref(false)
const paymentSubmitting = ref(false)
const userFormRef = ref()
const expiryFormRef = ref()
const paymentFormRef = ref()
// 分页信息
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
phone: '',
real_name: '',
system_role: '',
identity_code: ''
})
// 用户表单
const userForm = reactive({
phone: '',
password: '',
system_role: 2,
customer_name: '',
real_name: '',
company_name: '',
referrer_identity_code: '',
personal_intro: '',
status: 1
})
// 当前查看的用户
const currentUser = ref({})
// 当前设置过期时间的用户
const expiryUser = ref({})
// 过期时间表单
const expiryForm = reactive({
type: 'never',
expiryDate: null
})
// 当前编辑付款码的用户
const paymentUser = ref({})
// 付款码表单
const paymentForm = reactive({
main_payment_qr_image: '',
sub_payment_qr_image: ''
})
// 表单验证规则
const userRules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
system_role: [
{ required: true, message: '请选择用户角色', trigger: 'change' }
],
customer_name: [
{ required: true, message: '请输入客户姓名', trigger: 'blur' }
]
}
// 获取用户列表
const getUsers = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await axios.get('/admin/users', { params })
if (response.data.success) {
users.value = response.data.data.list || []
pagination.total = response.data.data.total || 0
}
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 重置搜索
const resetSearch = () => {
Object.assign(searchForm, {
phone: '',
real_name: '',
system_role: '',
identity_code: ''
})
pagination.page = 1
getUsers()
}
// 处理排序
const handleSortChange = ({ prop, order }) => {
if (!prop) return
// 复制用户数组进行排序
const sortedUsers = [...users.value]
sortedUsers.sort((a, b) => {
let aVal = a[prop]
let bVal = b[prop]
// 处理不同类型的字段排序
switch (prop) {
case 'system_role':
// 角色排序:管理员(0) < 代理商(1) < 普通用户(2)
aVal = aVal || 999
bVal = bVal || 999
break
case 'status':
// 状态排序:禁用(0) < 启用(1)
aVal = aVal || 0
bVal = bVal || 0
break
case 'expiry_date':
// 过期时间排序:永不过期(null) < 已过期 < 未过期
if (!aVal && !bVal) {
aVal = bVal = 0 // 都是永不过期,相等
} else if (!aVal) {
aVal = -1 // 永不过期排在前面
} else if (!bVal) {
bVal = -1
} else {
const aExpired = isExpired(aVal)
const bExpired = isExpired(bVal)
if (aExpired && !bExpired) {
aVal = 1 // 已过期排后面
bVal = 0 // 未过期排前面
} else if (!aExpired && bExpired) {
aVal = 0
bVal = 1
} else {
// 都是已过期或都是未过期,按时间排序
aVal = new Date(aVal).getTime()
bVal = new Date(bVal).getTime()
}
}
break
case 'current_points':
// 积分排序:null转为0
aVal = aVal || 0
bVal = bVal || 0
break
default:
// 默认排序处理
if (aVal == null) aVal = ''
if (bVal == null) bVal = ''
break
}
// 比较值
if (aVal < bVal) {
return order === 'ascending' ? -1 : 1
} else if (aVal > bVal) {
return order === 'ascending' ? 1 : -1
} else {
return 0
}
})
// 更新用户列表
users.value = sortedUsers
// 提示用户
if (order) {
const orderText = order === 'ascending' ? '升序' : '降序'
console.log(`已按${prop}进行${orderText}排序`)
} else {
console.log('已取消排序')
}
}
// 获取角色名称
const getRoleName = (role) => {
const roleMap = { 0: '管理员', 1: '代理商', 2: '普通用户' }
return roleMap[role] || '未知'
}
// 获取角色标签类型
const getRoleTagType = (role) => {
const typeMap = { 0: '', 1: 'warning', 2: 'info' }
return typeMap[role] || 'info'
}
// 格式化日期
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 判断是否已过期
const isExpired = (expiryDate) => {
if (!expiryDate) return false
return dayjs(expiryDate).isBefore(dayjs())
}
// 禁用过去的日期
const disabledDate = (time) => {
return time.getTime() < Date.now() - 8.64e7 // 禁用昨天之前的日期
}
// 显示创建对话框
const showCreateDialog = () => {
dialogMode.value = 'create'
resetUserForm()
dialogVisible.value = true
}
// 显示编辑对话框
const showEditDialog = (user) => {
dialogMode.value = 'edit'
Object.assign(userForm, {
...user,
password: '' // 编辑时不显示密码
})
dialogVisible.value = true
}
// 显示用户详情
const showUserDetail = (user) => {
currentUser.value = user
detailVisible.value = true
}
// 显示设置过期时间对话框
const showExpiryDialog = (user) => {
expiryUser.value = user
expiryForm.type = user.expiry_date ? 'custom' : 'never'
expiryForm.expiryDate = user.expiry_date ? dayjs(user.expiry_date).format('YYYY-MM-DDTHH:mm:ssZ') : null
expiryVisible.value = true
}
// 重置用户表单
const resetUserForm = () => {
Object.assign(userForm, {
phone: '',
password: '',
system_role: 2,
customer_name: '',
real_name: '',
company_name: '',
referrer_identity_code: '',
personal_intro: '',
status: 1
})
if (userFormRef.value) {
userFormRef.value.resetFields()
}
}
// 提交用户表单
const submitUser = async () => {
if (!userFormRef.value) return
const valid = await userFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (dialogMode.value === 'create') {
Object.assign(userForm, {
id: null,
status: 0
})
await axios.post('/admin/users', userForm)
ElMessage.success('用户创建成功')
} else {
const { id, ...updateData } = userForm
if (!updateData.password) {
delete updateData.password
}
await axios.put(`/admin/users/${id}`, updateData)
ElMessage.success('用户更新成功')
}
dialogVisible.value = false
getUsers()
} catch (error) {
console.error('提交用户失败:', error)
} finally {
submitting.value = false
}
}
// 提交过期时间设置
const submitExpiry = async () => {
expirySubmitting.value = true
try {
const expiryDate = expiryForm.type === 'never' ? null : expiryForm.expiryDate
await axios.put(`/admin/users/${expiryUser.value.id}/expiry`, {
expiry_date: expiryDate
})
ElMessage.success('用户过期时间设置成功')
expiryVisible.value = false
getUsers() // 刷新列表
} catch (error) {
console.error('设置过期时间失败:', error)
ElMessage.error('设置过期时间失败')
} finally {
expirySubmitting.value = false
}
}
// 显示付款码管理对话框
const showPaymentDialog = async (user) => {
paymentUser.value = user
// 获取用户当前的付款信息(直接从用户信息中获取)
Object.assign(paymentForm, {
main_payment_qr_image: user.main_payment_qr_image || '',
sub_payment_qr_image: user.sub_payment_qr_image || ''
})
paymentVisible.value = true
}
// 重置付款表单
const resetPaymentForm = () => {
Object.assign(paymentForm, {
main_payment_qr_image: '',
sub_payment_qr_image: ''
})
}
// 更新用户付款信息
const updatePaymentInfo = async () => {
paymentSubmitting.value = true
try {
paymentUser.value.main_payment_qr_image= paymentForm.main_payment_qr_image
paymentUser.value.sub_payment_qr_image= paymentForm.sub_payment_qr_image
const { data } = await axios.put(`/admin/users/${paymentUser.value.id}`, paymentUser.value)
if (data.success) {
ElMessage.success('用户付款信息更新成功')
paymentVisible.value = false
getUsers() // 刷新用户列表
} else {
ElMessage.error(data.message || '更新失败')
}
} catch (error) {
console.error('更新用户付款信息失败:', error)
ElMessage.error('更新失败')
} finally {
paymentSubmitting.value = false
}
}
// 获取完整图片URL
const getFullImageUrl = (url) => {
return url
}
// 删除用户
const deleteUser = async (user) => {
try {
await ElMessageBox.confirm(
`确定要删除用户 "${user.customer_name || user.phone}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await axios.delete(`/admin/users/${user.id}`)
ElMessage.success('用户删除成功')
getUsers()
} catch (error) {
if (error !== 'cancel') {
console.error('删除用户失败:', error)
}
}
}
// 组件挂载后获取数据
onMounted(() => {
getUsers()
})
</script>
<style scoped>
.users-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
margin-bottom: -18px;
}
.table-card {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.danger-text {
color: #f56c6c;
}
.danger-text:hover {
color: #f78989;
}
.dialog-footer {
text-align: right;
}
.expired-text {
color: #f56c6c;
font-weight: bold;
}
.payment-info {
background-color: #f5f7fa;
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
}
.payment-info p {
margin: 0 0 8px 0;
color: #606266;
}
.payment-info p:last-child {
margin-bottom: 0;
}
.upload-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form .el-form-item {
width: 100%;
margin-right: 0;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
</style>
@@ -0,0 +1,788 @@
<template>
<div class="purchase-orders-page">
<div class="page-header">
<h2>买单管理</h2>
</div>
<!-- 搜索筛选 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" :inline="true" class="search-form">
<el-form-item label="订单编号">
<el-input
v-model="searchForm.order_no"
placeholder="请输入订单编号"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="订单状态">
<el-select
v-model="searchForm.order_status"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<el-option label="待付款" value="0" />
<el-option label="待确认" value="1" />
<el-option label="已完成" value="2" />
<el-option label="已取消" value="3" />
</el-select>
</el-form-item>
<el-form-item label="商品类型">
<el-select
v-model="searchForm.product_type"
placeholder="请选择类型"
clearable
style="width: 150px"
>
<el-option label="一级商品" value="1" />
<el-option label="二级商品" value="2" />
</el-select>
</el-form-item>
<el-form-item label="卖家名字">
<el-input
v-model="searchForm.seller_name"
placeholder="请输入卖家名字"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="买家名字">
<el-input
v-model="searchForm.buyer_name"
placeholder="请输入买家名字"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getOrders">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 订单列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="orders"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="order_no" label="订单编号" width="180" />
<el-table-column label="买家信息" width="140">
<template #default="{ row }">
<div>{{ row.buyer?.real_name || row.buyer?.phone }}</div>
<div class="text-xs text-gray-500">{{ row.buyer?.phone }}</div>
</template>
</el-table-column>
<el-table-column label="卖家信息" width="140">
<template #default="{ row }">
<div v-if="row.seller">
{{ row.seller?.real_name || row.seller?.phone }}
<div class="text-xs text-gray-500">{{ row.seller?.phone }}</div>
</div>
<span v-else class="text-gray-400">平台商品</span>
</template>
</el-table-column>
<el-table-column prop="product_name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="product_type" label="商品类型" width="100">
<template #default="{ row }">
<el-tag :type="row.product_type === 1 ? 'primary' : 'warning'" size="small">
{{ row.product_type === 1 ? '一级商品' : '二级商品' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="unit_price" label="单价" width="100">
<template #default="{ row }">
¥{{ formatAmount(row.unit_price) }}
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="total_amount" label="总金额" width="120">
<template #default="{ row }">
<span class="font-semibold text-red-500">¥{{ formatAmount(row.total_amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="order_status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.order_status)" size="small">
{{ getStatusName(row.order_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<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="text" size="small" @click="showOrderDetail(row)">
查看
</el-button>
<el-button type="text" size="small" @click="showMessages(row)">
留言
</el-button>
<el-button
v-if="authStore.isAdmin && row.order_status === 1"
type="text"
size="small"
@click="showStatusDialog(row)"
>
处理
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getOrders"
@current-change="getOrders"
/>
</div>
</el-card>
<!-- 订单详情对话框 -->
<el-dialog
v-model="detailVisible"
title="买单详情"
width="800px"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="订单编号" :span="2">{{ currentOrder.order_no }}</el-descriptions-item>
<el-descriptions-item label="买家姓名">{{ currentOrder.buyer?.real_name }}</el-descriptions-item>
<el-descriptions-item label="买家手机">{{ currentOrder.buyer?.phone }}</el-descriptions-item>
<el-descriptions-item label="卖家姓名">{{ currentOrder.seller?.real_name || '平台' }}</el-descriptions-item>
<el-descriptions-item label="卖家手机">{{ currentOrder.seller?.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品名称" :span="2">{{ currentOrder.product_name }}</el-descriptions-item>
<el-descriptions-item label="商品类型">
<el-tag :type="currentOrder.product_type === 1 ? 'primary' : 'warning'">
{{ currentOrder.product_type === 1 ? '一级商品' : '二级商品' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag :type="getStatusTagType(currentOrder.order_status)">
{{ getStatusName(currentOrder.order_status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="单价">¥{{ formatAmount(currentOrder.unit_price) }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ currentOrder.quantity }}</el-descriptions-item>
<el-descriptions-item label="总金额" :span="2">
<span class="text-red-500 font-semibold text-lg">¥{{ formatAmount(currentOrder.total_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="收货地址" :span="2">
<div v-if="currentOrder.address">
{{ currentOrder.address.consignee_name }} {{ currentOrder.address.consignee_phone }}<br>
{{ currentOrder.address.province }}{{ currentOrder.address.city }}{{ currentOrder.address.district }}{{ currentOrder.address.detail_address }}
</div>
<span v-else>暂无</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentOrder.remark || '无' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentOrder.created_at) }}</el-descriptions-item>
<el-descriptions-item label="确认时间">{{ formatDate(currentOrder.confirm_time) || '未确认' }}</el-descriptions-item>
</el-descriptions>
<!-- 商品图片 -->
<div v-if="currentOrder.product_images" class="mt-4">
<h4>商品图片</h4>
<div class="image-gallery">
<el-image
v-for="(image, index) in getProductImages(currentOrder.product_images)"
:key="index"
:src="image"
:preview-src-list="getProductImages(currentOrder.product_images)"
fit="cover"
class="product-image"
/>
</div>
</div>
<div class="mt-4">
<h4>付款凭证</h4>
<div v-if="currentOrder.payment_proof_image" class="image-gallery">
<el-image
:src="currentOrder.payment_proof_image"
alt="无"
fit="cover"
:preview-src-list="[currentOrder.payment_proof_image]"
class="product-image"
/>
</div>
<div v-else>
</div>
</div>
</el-dialog>
<!-- 订单状态处理对话框 -->
<el-dialog
v-model="statusVisible"
title="处理订单"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="statusFormRef"
:model="statusForm"
:rules="statusRules"
label-width="100px"
>
<el-form-item label="处理结果" prop="order_status">
<el-radio-group v-model="statusForm.order_status">
<el-radio :label="2">确认完成</el-radio>
<el-radio :label="3">取消订单</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="处理备注" prop="remark">
<el-input
v-model="statusForm.remark"
type="textarea"
:rows="4"
placeholder="请输入处理备注"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="statusVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitStatus">
确定
</el-button>
</span>
</template>
</el-dialog>
<!-- 订单留言对话框 -->
<el-dialog
v-model="messagesVisible"
:title="`订单留言 - ${currentOrder.order_no}`"
width="800px"
:close-on-click-modal="false"
>
<!-- 留言列表 -->
<div class="messages-container">
<div class="messages-list">
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ 'admin-message': message.user?.system_role === 0 || message.user?.system_role === 1 }"
>
<div class="message-header">
<span class="user-name">
{{ message.user?.real_name || message.user?.customer_name }}
<el-tag
v-if="message.user?.system_role === 0"
type="danger"
size="small"
>
管理员
</el-tag>
<el-tag
v-else-if="message.user?.system_role === 1"
type="warning"
size="small"
>
代理商
</el-tag>
</span>
<span class="message-time">{{ formatDate(message.created_at) }}</span>
</div>
<div class="message-content">{{ message.content }}</div>
<!-- 留言图片 -->
<div v-if="message.images" class="message-images">
<el-image
v-for="(image, index) in getMessageImages(message.images)"
:key="index"
:src="(image)"
:preview-src-list="getMessageImages(message.images)"
:initial-index="index"
class="message-image"
fit="cover"
preview-teleported
/>
</div>
</div>
<div v-if="messages.length === 0" class="empty-messages">
暂无留言
</div>
</div>
<!-- 发送留言 -->
<div class="send-message">
<el-form :model="messageForm" @submit.prevent="sendMessage">
<el-form-item>
<el-input
v-model="messageForm.content"
type="textarea"
:rows="3"
placeholder="请输入留言内容(最多500字)"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="sendingMessage"
@click="sendMessage"
:disabled="!messageForm.content.trim()"
>
发送留言
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="messagesVisible = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import axios from 'axios'
import dayjs from 'dayjs'
const authStore = useAuthStore()
// 响应式数据
const loading = ref(false)
const orders = ref([])
const detailVisible = ref(false)
const statusVisible = ref(false)
const submitting = ref(false)
const statusFormRef = ref()
// 分页信息
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
order_no: '',
order_status: '',
product_type: '',
seller_name: '',
buyer_name: ''
})
// 当前查看的订单
const currentOrder = ref({})
// 状态处理表单
const statusForm = reactive({
order_status: 2,
remark: ''
})
// 状态处理验证规则
const statusRules = {
order_status: [
{ required: true, message: '请选择处理结果', trigger: 'change' }
],
remark: [
{ required: true, message: '请输入处理备注', trigger: 'blur' }
]
}
// 留言相关数据
const messagesVisible = ref(false)
const messages = ref([])
const sendingMessage = ref(false)
const messageForm = reactive({
content: ''
})
// 获取订单列表
const getOrders = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await axios.get('/admin/orders/purchase', { params })
if (response.data.success) {
orders.value = response.data.data.list || []
pagination.total = response.data.data.total || 0
}
} catch (error) {
console.error('获取买单列表失败:', error)
} finally {
loading.value = false
}
}
// 重置搜索
const resetSearch = () => {
Object.assign(searchForm, {
order_no: '',
order_status: '',
product_type: '',
seller_name: '',
buyer_name: ''
})
pagination.page = 1
getOrders()
}
// 获取状态名称
const getStatusName = (status) => {
const statusMap = { 0: '待付款', 1: '待确认', 2: '已完成', 3: '已取消' }
return statusMap[status] || '未知'
}
// 获取状态标签类型
const getStatusTagType = (status) => {
const typeMap = { 0: 'warning', 1: 'primary', 2: 'success', 3: 'danger' }
return typeMap[status] || 'info'
}
// 格式化金额
const formatAmount = (amount) => {
if (!amount) return '0.00'
return Number(amount).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 获取留言图片列表
const getMessageImages = (images) => {
if (!images) return []
return images.split(',').filter(img => img.trim())
}
// 获取图片完整URL
const getImageUrl = (imageUrl) => {
if (!imageUrl) return ''
if (imageUrl.startsWith('http')) return imageUrl
return `${axios.defaults.baseURL.replace('/back', '')}/file/${imageUrl}`
}
// 获取商品图片列表
const getProductImages = (images) => {
if (!images) return []
return images.split(',').filter(img => img.trim())
}
// 显示订单详情
const showOrderDetail = (order) => {
currentOrder.value = order
detailVisible.value = true
}
// 显示状态处理对话框
const showStatusDialog = (order) => {
currentOrder.value = order
statusForm.order_status = 2
statusForm.remark = ''
statusVisible.value = true
}
// 提交状态处理
const submitStatus = async () => {
if (!statusFormRef.value) return
const valid = await statusFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await axios.put(`/admin/orders/purchase/${currentOrder.value.id}/status`, statusForm)
ElMessage.success('订单处理成功')
statusVisible.value = false
getOrders()
} catch (error) {
console.error('处理订单失败:', error)
} finally {
submitting.value = false
}
}
// 显示留言对话框
const showMessages = async (order) => {
currentOrder.value = order
messagesVisible.value = true
await getMessages()
}
// 获取订单留言
const getMessages = async () => {
try {
const response = await axios.get(`/admin/orders/${currentOrder.value.id}/messages`, {
params: {
type: 'purchase'
}
})
if (response.data.success) {
messages.value = response.data.data.list || []
} else {
ElMessage.error(response.data.message || '获取留言失败')
}
} catch (error) {
console.error('获取留言失败:', error)
ElMessage.error('获取留言失败')
}
}
// 发送留言
const sendMessage = async () => {
if (!messageForm.content.trim()) {
ElMessage.warning('请输入留言内容')
return
}
sendingMessage.value = true
try {
const response = await axios.post(`/admin/orders/${currentOrder.value.id}/messages`, {
content: messageForm.content.trim()
}, {
params: {
type: 'purchase'
}
})
if (response.data.success) {
ElMessage.success('留言发送成功')
messageForm.content = ''
await getMessages() // 刷新留言列表
} else {
ElMessage.error(response.data.message || '发送留言失败')
}
} catch (error) {
console.error('发送留言失败:', error)
ElMessage.error('发送留言失败')
} finally {
sendingMessage.value = false
}
}
// 组件挂载后获取数据
onMounted(() => {
getOrders()
})
</script>
<style scoped>
.purchase-orders-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
margin-bottom: -18px;
}
.table-card {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.text-xs {
font-size: 12px;
}
.text-gray-400 {
color: #a0a0a0;
}
.text-gray-500 {
color: #6b7280;
}
.text-red-500 {
color: #ef4444;
}
.font-semibold {
font-weight: 600;
}
.image-gallery {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 4px;
}
.dialog-footer {
text-align: right;
}
/* 留言相关样式 */
.messages-container {
max-height: 500px;
display: flex;
flex-direction: column;
}
.messages-list {
flex: 1;
max-height: 350px;
overflow-y: auto;
padding: 10px;
border: 1px solid #e4e7ed;
border-radius: 4px;
margin-bottom: 20px;
}
.message-item {
margin-bottom: 16px;
padding: 12px;
border-radius: 8px;
background-color: #f8f9fa;
}
.message-item.admin-message {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.user-name {
font-weight: 500;
color: #303133;
}
.message-time {
font-size: 12px;
color: #909399;
}
.message-content {
color: #606266;
line-height: 1.5;
white-space: pre-wrap;
}
.empty-messages {
text-align: center;
color: #909399;
padding: 40px 0;
}
.send-message {
border-top: 1px solid #e4e7ed;
padding-top: 20px;
}
/* 留言图片样式 */
.message-images {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.message-image {
width: 80px;
height: 80px;
border-radius: 4px;
cursor: pointer;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form .el-form-item {
width: 100%;
margin-right: 0;
}
}
</style>
@@ -0,0 +1,764 @@
<template>
<div class="sales-orders-page">
<div class="page-header">
<h2>卖单管理</h2>
</div>
<!-- 搜索筛选 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" :inline="true" class="search-form">
<el-form-item label="订单编号">
<el-input
v-model="searchForm.order_no"
placeholder="请输入订单编号"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="订单状态">
<el-select
v-model="searchForm.order_status"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<el-option label="待付款" value="0" />
<el-option label="待发货" value="1" />
<el-option label="确认完成" value="2" />
<el-option label="已取消" value="3" />
</el-select>
</el-form-item>
<el-form-item label="卖家名字">
<el-input
v-model="searchForm.seller_name"
placeholder="请输入卖家名字"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="买家名字">
<el-input
v-model="searchForm.buyer_name"
placeholder="请输入买家名字"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getOrders">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 订单列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="orders"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="order_no" label="订单编号" width="180" />
<el-table-column label="卖家信息" width="140">
<template #default="{ row }">
<div>{{ row.seller?.real_name || row.seller?.phone }}</div>
<div class="text-xs text-gray-500">{{ row.seller?.phone }}</div>
</template>
</el-table-column>
<el-table-column label="买家信息" width="140">
<template #default="{ row }">
<div>{{ row.buyer?.real_name || row.buyer?.phone }}</div>
<div class="text-xs text-gray-500">{{ row.buyer?.phone }}</div>
</template>
</el-table-column>
<el-table-column prop="product_name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="selling_price" label="售价" width="100">
<template #default="{ row }">
¥{{ formatAmount(row.selling_price) }}
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="total_amount" label="总金额" width="120">
<template #default="{ row }">
<span class="font-semibold text-red-500">¥{{ formatAmount(row.total_amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="order_status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.order_status)" size="small">
{{ getStatusName(row.order_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<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="text" size="small" @click="showOrderDetail(row)">
查看
</el-button>
<el-button type="text" size="small" @click="showMessages(row)">
留言
</el-button>
<el-button
v-if="authStore.isAdmin && row.order_status === 1"
type="text"
size="small"
@click="showStatusDialog(row)"
>
处理
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getOrders"
@current-change="getOrders"
/>
</div>
</el-card>
<!-- 订单详情对话框 -->
<el-dialog
v-model="detailVisible"
title="卖单详情"
width="800px"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="订单编号" :span="2">{{ currentOrder.order_no }}</el-descriptions-item>
<el-descriptions-item label="卖家姓名">{{ currentOrder.seller?.real_name }}</el-descriptions-item>
<el-descriptions-item label="卖家手机">{{ currentOrder.seller?.phone }}</el-descriptions-item>
<el-descriptions-item label="买家姓名">{{ currentOrder.buyer?.real_name }}</el-descriptions-item>
<el-descriptions-item label="买家手机">{{ currentOrder.buyer?.phone }}</el-descriptions-item>
<el-descriptions-item label="商品名称" :span="2">{{ currentOrder.product_name }}</el-descriptions-item>
<el-descriptions-item label="订单状态" :span="2">
<el-tag :type="getStatusTagType(currentOrder.order_status)">
{{ getStatusName(currentOrder.order_status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="售价">¥{{ formatAmount(currentOrder.selling_price) }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ currentOrder.quantity }}</el-descriptions-item>
<el-descriptions-item label="总金额" :span="2">
<span class="text-red-500 font-semibold text-lg">¥{{ formatAmount(currentOrder.total_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="收货地址" :span="2">
<div v-if="currentOrder.address">
{{ currentOrder.address.consignee_name }} {{ currentOrder.address.consignee_phone }}<br>
{{ currentOrder.address.province }}{{ currentOrder.address.city }}{{ currentOrder.address.district }}{{ currentOrder.address.detail_address }}
</div>
<span v-else>暂无</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentOrder.remark || '无' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentOrder.created_at) }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ formatDate(currentOrder.complete_time) || '未完成' }}</el-descriptions-item>
</el-descriptions>
<!-- 商品信息 -->
<div v-if="currentOrder.secondary_product" class="mt-4">
<h4>关联商品信息</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="商品ID">{{ currentOrder.secondary_product.id }}</el-descriptions-item>
<el-descriptions-item label="成本价">¥{{ formatAmount(currentOrder.secondary_product.cost_price) }}</el-descriptions-item>
<el-descriptions-item label="商品描述" :span="2">{{ currentOrder.secondary_product.product_description || '无' }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 商品图片 -->
<div v-if="currentOrder.product_images" class="mt-4">
<h4>商品图片</h4>
<div class="image-gallery">
<el-image
v-for="(image, index) in getProductImages(currentOrder.product_images)"
:key="index"
:src="image"
:preview-src-list="getProductImages(currentOrder.product_images)"
fit="cover"
class="product-image"
/>
</div>
</div>
<div class="mt-4">
<h4>付款凭证</h4>
<div v-if="currentOrder.payment_proof_image" class="image-gallery">
<el-image
:preview-src-list="[currentOrder.payment_proof_image]"
:src="currentOrder.payment_proof_image"
alt="无"
fit="cover"
class="product-image"
/>
</div>
<div v-else>
</div>
</div>
</el-dialog>
<!-- 订单状态处理对话框 -->
<el-dialog
v-model="statusVisible"
title="处理订单"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="statusFormRef"
:model="statusForm"
:rules="statusRules"
label-width="100px"
>
<el-form-item label="处理结果" prop="order_status">
<el-radio-group v-model="statusForm.order_status">
<el-radio :label="2">确认完成</el-radio>
<el-radio :label="3">取消订单</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="处理备注" prop="remark">
<el-input
v-model="statusForm.remark"
type="textarea"
:rows="4"
placeholder="请输入处理备注"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="statusVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitStatus">
确定
</el-button>
</span>
</template>
</el-dialog>
<!-- 订单留言对话框 -->
<el-dialog
v-model="messagesVisible"
:title="`订单留言 - ${currentOrder.order_no}`"
width="800px"
:close-on-click-modal="false"
>
<!-- 留言列表 -->
<div class="messages-container">
<div class="messages-list">
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ 'admin-message': message.user?.system_role === 0 || message.user?.system_role === 1 }"
>
<div class="message-header">
<span class="user-name">
{{ message.user?.real_name || message.user?.customer_name }}
<el-tag
v-if="message.user?.system_role === 0"
type="danger"
size="small"
>
管理员
</el-tag>
<el-tag
v-else-if="message.user?.system_role === 1"
type="warning"
size="small"
>
代理商
</el-tag>
</span>
<span class="message-time">{{ formatDate(message.created_at) }}</span>
</div>
<div class="message-content">{{ message.content }}</div>
<!-- 留言图片 -->
<div v-if="message.images" class="message-images">
<el-image
v-for="(image, index) in getMessageImages(message.images)"
:key="index"
:src="(image)"
:preview-src-list="getMessageImages(message.images)"
:initial-index="index"
class="message-image"
fit="cover"
preview-teleported
/>
</div>
</div>
<div v-if="messages.length === 0" class="empty-messages">
暂无留言
</div>
</div>
<!-- 发送留言 -->
<div class="send-message">
<el-form :model="messageForm" @submit.prevent="sendMessage">
<el-form-item>
<el-input
v-model="messageForm.content"
type="textarea"
:rows="3"
placeholder="请输入留言内容(最多500字)"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="sendingMessage"
@click="sendMessage"
:disabled="!messageForm.content.trim()"
>
发送留言
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="messagesVisible = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import axios from 'axios'
import dayjs from 'dayjs'
const authStore = useAuthStore()
// 响应式数据
const loading = ref(false)
const orders = ref([])
const detailVisible = ref(false)
const statusVisible = ref(false)
const submitting = ref(false)
const statusFormRef = ref()
// 分页信息
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
order_no: '',
order_status: '',
seller_name: '',
buyer_name: ''
})
// 当前查看的订单
const currentOrder = ref({})
// 状态处理表单
const statusForm = reactive({
order_status: 2,
remark: ''
})
// 状态处理验证规则
const statusRules = {
order_status: [
{ required: true, message: '请选择处理结果', trigger: 'change' }
],
remark: [
{ required: true, message: '请输入处理备注', trigger: 'blur' }
]
}
// 留言相关数据
const messagesVisible = ref(false)
const messages = ref([])
const sendingMessage = ref(false)
const messageForm = reactive({
content: ''
})
// 获取订单列表
const getOrders = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await axios.get('/admin/orders/sales', { params })
if (response.data.success) {
orders.value = response.data.data.list || []
pagination.total = response.data.data.total || 0
}
} catch (error) {
console.error('获取卖单列表失败:', error)
} finally {
loading.value = false
}
}
// 重置搜索
const resetSearch = () => {
Object.assign(searchForm, {
order_no: '',
order_status: '',
seller_name: '',
buyer_name: ''
})
pagination.page = 1
getOrders()
}
// 获取状态名称
const getStatusName = (status) => {
const statusMap = { 0: '待付款', 1: '待发货', 2: '确认完成', 3: '已取消' }
return statusMap[status] || '未知'
}
// 获取状态标签类型
const getStatusTagType = (status) => {
const typeMap = { 0: 'warning', 1: 'primary', 2: 'success', 3: 'danger' }
return typeMap[status] || 'info'
}
// 格式化金额
const formatAmount = (amount) => {
if (!amount) return '0.00'
return Number(amount).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 获取留言图片列表
const getMessageImages = (images) => {
if (!images) return []
return images.split(',').filter(img => img.trim())
}
// 获取图片完整URL
const getImageUrl = (imageUrl) => {
if (!imageUrl) return ''
if (imageUrl.startsWith('http')) return imageUrl
return `${axios.defaults.baseURL.replace('/back', '')}/file/${imageUrl}`
}
// 获取商品图片列表
const getProductImages = (images) => {
if (!images) return []
return images.split(',').filter(img => img.trim())
}
// 显示订单详情
const showOrderDetail = (order) => {
currentOrder.value = order
detailVisible.value = true
}
// 显示状态处理对话框
const showStatusDialog = (order) => {
currentOrder.value = order
statusForm.order_status = 2
statusForm.remark = ''
statusVisible.value = true
}
// 提交状态处理
const submitStatus = async () => {
if (!statusFormRef.value) return
const valid = await statusFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await axios.put(`/admin/orders/sales/${currentOrder.value.id}/status`, statusForm)
ElMessage.success('订单处理成功')
statusVisible.value = false
getOrders()
} catch (error) {
console.error('处理订单失败:', error)
} finally {
submitting.value = false
}
}
// 显示留言对话框
const showMessages = async (order) => {
currentOrder.value = order
messagesVisible.value = true
await getMessages()
}
// 获取订单留言
const getMessages = async () => {
try {
const response = await axios.get(`/admin/orders/${currentOrder.value.id}/messages`, {
params: {
type: 'sales'
}
})
if (response.data.success) {
messages.value = response.data.data.list || []
} else {
ElMessage.error(response.data.message || '获取留言失败')
}
} catch (error) {
console.error('获取留言失败:', error)
ElMessage.error('获取留言失败')
}
}
// 发送留言
const sendMessage = async () => {
if (!messageForm.content.trim()) {
ElMessage.warning('请输入留言内容')
return
}
sendingMessage.value = true
try {
const response = await axios.post(`/admin/orders/${currentOrder.value.id}/messages`, {
content: messageForm.content.trim()
}, {
params: {
type: 'sales'
}
})
if (response.data.success) {
ElMessage.success('留言发送成功')
messageForm.content = ''
await getMessages() // 刷新留言列表
} else {
ElMessage.error(response.data.message || '发送留言失败')
}
} catch (error) {
console.error('发送留言失败:', error)
ElMessage.error('发送留言失败')
} finally {
sendingMessage.value = false
}
}
// 组件挂载后获取数据
onMounted(() => {
getOrders()
})
</script>
<style scoped>
.sales-orders-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
margin-bottom: -18px;
}
.table-card {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.text-xs {
font-size: 12px;
}
.text-gray-500 {
color: #6b7280;
}
.text-red-500 {
color: #ef4444;
}
.font-semibold {
font-weight: 600;
}
.image-gallery {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 4px;
}
.dialog-footer {
text-align: right;
}
/* 留言相关样式 */
.messages-container {
max-height: 500px;
display: flex;
flex-direction: column;
}
.messages-list {
flex: 1;
max-height: 350px;
overflow-y: auto;
padding: 10px;
border: 1px solid #e4e7ed;
border-radius: 4px;
margin-bottom: 20px;
}
.message-item {
margin-bottom: 16px;
padding: 12px;
border-radius: 8px;
background-color: #f8f9fa;
}
.message-item.admin-message {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.user-name {
font-weight: 500;
color: #303133;
}
.message-time {
font-size: 12px;
color: #909399;
}
.message-content {
color: #606266;
line-height: 1.5;
white-space: pre-wrap;
}
.empty-messages {
text-align: center;
color: #909399;
padding: 40px 0;
}
.send-message {
border-top: 1px solid #e4e7ed;
padding-top: 20px;
}
/* 留言图片样式 */
.message-images {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.message-image {
width: 80px;
height: 80px;
border-radius: 4px;
cursor: pointer;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form .el-form-item {
width: 100%;
margin-right: 0;
}
}
</style>
@@ -0,0 +1,345 @@
<template>
<div class="categories-page">
<div class="page-header">
<h2>商品分类管理</h2>
<el-button type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon>
添加分类
</el-button>
</div>
<!-- 分类列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="categories"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="分类图标" width="100">
<template #default="{ row }">
<el-image
v-if="row.category_icon"
:src="getImageUrl(row.category_icon)"
fit="cover"
style="width: 50px; height: 50px; border-radius: 4px;"
/>
<span v-else class="text-gray-400">无图标</span>
</template>
</el-table-column>
<el-table-column prop="category_name" label="分类名称" min-width="200" />
<el-table-column prop="sort_order" label="排序" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="updateStatus(row)"
/>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="text" size="small" @click="showEditDialog(row)">
编辑
</el-button>
<el-button
type="text"
size="small"
class="danger-text"
@click="deleteCategory(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建/编辑分类对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '添加分类' : '编辑分类'"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="categoryFormRef"
:model="categoryForm"
:rules="categoryRules"
label-width="100px"
>
<el-form-item label="分类名称" prop="category_name">
<el-input
v-model="categoryForm.category_name"
placeholder="请输入分类名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类图标">
<ImageUpload
v-model="categoryForm.category_icon"
:limit="1"
:multiple="false"
/>
</el-form-item>
<el-form-item label="排序序号" prop="sort_order">
<el-input-number
v-model="categoryForm.sort_order"
:min="0"
:max="9999"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="categoryForm.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitCategory">
确定
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
import dayjs from 'dayjs'
import ImageUpload from '@/components/ImageUpload.vue'
// 响应式数据
const loading = ref(false)
const categories = ref([])
const dialogVisible = ref(false)
const dialogMode = ref('create')
const submitting = ref(false)
const categoryFormRef = ref()
// 分类表单
const categoryForm = reactive({
category_name: '',
category_icon: '',
sort_order: 0,
status: 1
})
// 表单验证规则
const categoryRules = {
category_name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 50, message: '分类名称不能超过50个字符', trigger: 'blur' }
],
sort_order: [
{ required: true, message: '请输入排序序号', trigger: 'blur' },
{ type: 'number', message: '排序序号必须为数字' }
]
}
// 获取分类列表
const getCategories = async () => {
loading.value = true
try {
const response = await axios.get('/admin/products/categories')
if (response.data.success) {
categories.value = response.data.data || []
}
} catch (error) {
console.error('获取分类列表失败:', error)
} finally {
loading.value = false
}
}
// 获取图片完整URL
const getImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${window.location.origin}${url}`
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 显示创建对话框
const showCreateDialog = () => {
dialogMode.value = 'create'
resetCategoryForm()
dialogVisible.value = true
}
// 显示编辑对话框
const showEditDialog = (category) => {
dialogMode.value = 'edit'
Object.assign(categoryForm, {
id: category.id,
category_name: category.category_name,
category_icon: category.category_icon || '',
sort_order: category.sort_order,
status: category.status
})
dialogVisible.value = true
}
// 重置分类表单
const resetCategoryForm = () => {
Object.assign(categoryForm, {
category_name: '',
category_icon: '',
sort_order: 0,
status: 1
})
if (categoryFormRef.value) {
categoryFormRef.value.resetFields()
}
// 确保图片上传组件也被重置
setTimeout(() => {
// 使用 nextTick 确保 DOM 更新完成
categoryForm.category_icon = ''
}, 100)
}
// 提交分类表单
const submitCategory = async () => {
if (!categoryFormRef.value) return
const valid = await categoryFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (dialogMode.value === 'create') {
await axios.post('/admin/products/categories', categoryForm)
ElMessage.success('分类创建成功')
} else {
const { id, ...updateData } = categoryForm
await axios.put(`/admin/products/categories/${id}`, updateData)
ElMessage.success('分类更新成功')
}
dialogVisible.value = false
// 重置表单,确保下次添加时状态正确
resetCategoryForm()
getCategories()
} catch (error) {
console.error('提交分类失败:', error)
} finally {
submitting.value = false
}
}
// 更新状态
const updateStatus = async (category) => {
try {
await axios.put(`/admin/products/categories/${category.id}`, {
status: category.status
})
ElMessage.success('状态更新成功')
} catch (error) {
console.error('更新状态失败:', error)
// 恢复原状态
category.status = category.status === 1 ? 0 : 1
}
}
// 删除分类
const deleteCategory = async (category) => {
try {
await ElMessageBox.confirm(
`确定要删除分类 "${category.category_name}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await axios.delete(`/admin/products/categories/${category.id}`)
ElMessage.success('分类删除成功')
getCategories()
} catch (error) {
if (error !== 'cancel') {
console.error('删除分类失败:', error)
}
}
}
// 组件挂载后获取数据
onMounted(() => {
getCategories()
})
</script>
<style scoped>
.categories-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.table-card {
margin-bottom: 20px;
}
.text-gray-400 {
color: #a0a0a0;
}
.danger-text {
color: #f56c6c;
}
.danger-text:hover {
color: #f78989;
}
.dialog-footer {
text-align: right;
}
</style>
@@ -0,0 +1,747 @@
<template>
<div class="primary-products-page">
<div class="page-header">
<h2>一级商品管理</h2>
<el-button type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon>
添加商品
</el-button>
</div>
<!-- 搜索筛选 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" :inline="true" class="search-form">
<el-form-item label="商品分类">
<el-select
v-model="searchForm.category_id"
placeholder="请选择分类"
clearable
style="width: 200px"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.category_name"
:value="category.id"
/>
</el-select>
</el-form-item>
<el-form-item label="商品名称">
<el-input
v-model="searchForm.product_name"
placeholder="请输入商品名称"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 120px"
>
<el-option label="上架" value="1" />
<el-option label="下架" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getProducts">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 商品列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="products"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="商品图片" width="100">
<template #default="{ row }">
<el-image
v-if="getProductImages(row.product_images)[0]"
:src="getImageUrl(getProductImages(row.product_images)[0])"
:preview-src-list="getProductImages(row.product_images).map(img => getImageUrl(img))"
fit="cover"
style="width: 60px; height: 60px; border-radius: 4px;"
/>
<span v-else class="text-gray-400">无图片</span>
</template>
</el-table-column>
<el-table-column prop="product_name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column label="分类" width="120">
<template #default="{ row }">
{{ row.category?.category_name || '未分类' }}
</template>
</el-table-column>
<el-table-column label="价格" width="150">
<template #default="{ row }">
<div>
<div class="text-red-500 font-semibold">¥{{ formatAmount(row.current_price) }}</div>
<div v-if="row.original_price !== row.current_price" class="text-gray-400 text-xs line-through">
¥{{ formatAmount(row.original_price) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="stock_quantity" label="库存" width="100" />
<el-table-column prop="sales_count" label="销量" width="100" />
<el-table-column prop="view_count" label="浏览量" width="100" />
<!-- <el-table-column prop="status" label="状态" width="100">-->
<!-- <template #default="{ row }">-->
<!-- <el-switch-->
<!-- v-model="row.status"-->
<!-- :active-value="1"-->
<!-- :inactive-value="0"-->
<!-- @change="updateStatus(row)"-->
<!-- />-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- -->
<!-- <el-table-column prop="is_hot" label="热门" width="100">-->
<!-- <template #default="{ row }">-->
<!-- <el-switch-->
<!-- v-model="row.is_hot"-->
<!-- :active-value="1"-->
<!-- :inactive-value="0"-->
<!-- @change="updateHotStatus(row)"-->
<!-- />-->
<!-- </template>-->
<!-- </el-table-column>-->
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="text" size="small" @click="showProductDetail(row)">
查看
</el-button>
<el-button type="text" size="small" @click="showEditDialog(row)">
编辑
</el-button>
<el-button
type="text"
size="small"
class="danger-text"
@click="deleteProduct(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getProducts"
@current-change="getProducts"
/>
</div>
</el-card>
<!-- 创建/编辑商品对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '添加商品' : '编辑商品'"
width="800px"
:close-on-click-modal="false"
>
<el-form
ref="productFormRef"
:model="productForm"
:rules="productRules"
label-width="100px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商品名称" prop="product_name">
<el-input
v-model="productForm.product_name"
placeholder="请输入商品名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品分类" prop="category_id">
<el-select
v-model="productForm.category_id"
placeholder="请选择分类"
style="width: 100%"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.category_name"
:value="category.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="商品描述">
<el-input
v-model="productForm.product_description"
type="textarea"
:rows="4"
placeholder="请输入商品描述"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="商品图片">
<ImageUpload
v-model="productForm.product_images"
:limit="9"
:multiple="true"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="原价" prop="original_price">
<el-input-number
v-model="productForm.original_price"
:min="0"
:precision="2"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="现价" prop="current_price">
<el-input-number
v-model="productForm.current_price"
:min="0"
:precision="2"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="库存数量" prop="stock_quantity">
<el-input-number
v-model="productForm.stock_quantity"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="排序序号">
<el-input-number
v-model="productForm.sort_order"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-radio-group v-model="productForm.status">
<el-radio :label="1">上架</el-radio>
<el-radio :label="0">下架</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="热门商品">
<el-switch
v-model="productForm.is_hot"
:active-value="1"
:inactive-value="0"
active-text=""
inactive-text=""
/>
</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" :loading="submitting" @click="submitProduct">
确定
</el-button>
</span>
</template>
</el-dialog>
<!-- 商品详情对话框 -->
<el-dialog
v-model="detailVisible"
title="商品详情"
width="800px"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="商品ID">{{ currentProduct.id }}</el-descriptions-item>
<el-descriptions-item label="商品名称">{{ currentProduct.product_name }}</el-descriptions-item>
<el-descriptions-item label="商品分类">{{ currentProduct.category?.category_name }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentProduct.status === 1 ? 'success' : 'danger'">
{{ currentProduct.status === 1 ? '上架' : '下架' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="热门商品">
<el-tag :type="currentProduct.is_hot === 1 ? 'warning' : 'info'">
{{ currentProduct.is_hot === 1 ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="原价">¥{{ formatAmount(currentProduct.original_price) }}</el-descriptions-item>
<el-descriptions-item label="现价">¥{{ formatAmount(currentProduct.current_price) }}</el-descriptions-item>
<el-descriptions-item label="库存数量">{{ currentProduct.stock_quantity }}</el-descriptions-item>
<el-descriptions-item label="销量">{{ currentProduct.sales_count }}</el-descriptions-item>
<el-descriptions-item label="浏览量">{{ currentProduct.view_count }}</el-descriptions-item>
<el-descriptions-item label="排序序号">{{ currentProduct.sort_order }}</el-descriptions-item>
<el-descriptions-item label="商品描述" :span="2">{{ currentProduct.product_description || '无' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentProduct.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(currentProduct.updated_at) }}</el-descriptions-item>
</el-descriptions>
<!-- 商品图片 -->
<div v-if="getProductImages(currentProduct.product_images).length > 0" class="mt-4">
<h4>商品图片</h4>
<div class="image-gallery">
<el-image
v-for="(image, index) in getProductImages(currentProduct.product_images)"
:key="index"
:src="getImageUrl(image)"
:preview-src-list="getProductImages(currentProduct.product_images).map(img => getImageUrl(img))"
fit="cover"
class="product-image"
/>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
import dayjs from 'dayjs'
import ImageUpload from '@/components/ImageUpload.vue'
// 响应式数据
const loading = ref(false)
const products = ref([])
const categories = ref([])
const dialogVisible = ref(false)
const detailVisible = ref(false)
const dialogMode = ref('create')
const submitting = ref(false)
const productFormRef = ref()
// 分页信息
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
category_id: '',
product_name: '',
status: ''
})
// 商品表单
const productForm = reactive({
product_name: '',
category_id: '',
product_description: '',
product_images: '',
original_price: 0,
current_price: 0,
stock_quantity: 0,
sort_order: 0,
status: 1,
is_hot: 0
})
// 当前查看的商品
const currentProduct = ref({})
// 表单验证规则
const productRules = {
product_name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' },
{ max: 100, message: '商品名称不能超过100个字符', trigger: 'blur' }
],
category_id: [
{ required: true, message: '请选择商品分类', trigger: 'change' }
],
original_price: [
{ required: true, message: '请输入原价', trigger: 'blur' },
{ type: 'number', min: 0, message: '原价不能小于0', trigger: 'blur' }
],
current_price: [
{ required: true, message: '请输入现价', trigger: 'blur' },
{ type: 'number', min: 0, message: '现价不能小于0', trigger: 'blur' }
],
stock_quantity: [
{ required: true, message: '请输入库存数量', trigger: 'blur' },
{ type: 'number', min: 0, message: '库存数量不能小于0', trigger: 'blur' }
]
}
// 获取商品分类
const getCategories = async () => {
try {
const response = await axios.get('/admin/products/categories')
if (response.data.success) {
categories.value = response.data.data || []
}
} catch (error) {
console.error('获取分类列表失败:', error)
}
}
// 获取商品列表
const getProducts = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await axios.get('/admin/products/primary', { params })
if (response.data.success) {
products.value = response.data.data.list || []
pagination.total = response.data.data.total || 0
}
} catch (error) {
console.error('获取商品列表失败:', error)
} finally {
loading.value = false
}
}
// 重置搜索
const resetSearch = () => {
Object.assign(searchForm, {
category_id: '',
product_name: '',
status: ''
})
pagination.page = 1
getProducts()
}
// 获取图片完整URL
const getImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${window.location.origin}${url}`
}
// 获取商品图片列表
const getProductImages = (images) => {
if (!images) return []
return images.split(',').filter(img => img.trim())
}
// 格式化金额
const formatAmount = (amount) => {
if (!amount) return '0.00'
return Number(amount).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 显示创建对话框
const showCreateDialog = () => {
dialogMode.value = 'create'
resetProductForm()
dialogVisible.value = true
}
// 显示编辑对话框
const showEditDialog = (product) => {
dialogMode.value = 'edit'
Object.assign(productForm, {
id: product.id,
product_name: product.product_name,
category_id: product.category_id,
product_description: product.product_description,
product_images: product.product_images || '',
original_price: product.original_price,
current_price: product.current_price,
stock_quantity: product.stock_quantity,
sort_order: product.sort_order,
status: product.status,
is_hot: product.is_hot || 0
})
dialogVisible.value = true
}
// 显示商品详情
const showProductDetail = (product) => {
currentProduct.value = product
detailVisible.value = true
}
// 重置商品表单
const resetProductForm = () => {
Object.assign(productForm, {
product_name: '',
category_id: '',
product_description: '',
product_images: '',
original_price: 0,
current_price: 0,
stock_quantity: 0,
sort_order: 0,
status: 1,
is_hot: 0
})
if (productFormRef.value) {
productFormRef.value.resetFields()
}
}
// 提交商品表单
const submitProduct = async () => {
if (!productFormRef.value) return
const valid = await productFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (dialogMode.value === 'create') {
await axios.post('/admin/products/primary', productForm)
ElMessage.success('商品创建成功')
} else {
const { id, ...updateData } = productForm
await axios.put(`/admin/products/primary/${id}`, updateData)
ElMessage.success('商品更新成功')
}
dialogVisible.value = false
getProducts()
} catch (error) {
console.error('提交商品失败:', error)
} finally {
submitting.value = false
}
}
// 更新状态
const updateStatus = async (product) => {
try {
await axios.put(`/admin/products/primary/${product.id}`, {
status: product.status
})
ElMessage.success('状态更新成功')
} catch (error) {
console.error('更新状态失败:', error)
// 恢复原状态
product.status = product.status === 1 ? 0 : 1
}
}
// 更新热门状态
const updateHotStatus = async (product) => {
try {
await axios.put(`/admin/products/primary/${product.id}`, {
is_hot: product.is_hot
})
ElMessage.success('热门状态更新成功')
} catch (error) {
console.error('更新热门状态失败:', error)
// 恢复原状态
product.is_hot = product.is_hot === 1 ? 0 : 1
}
}
// 删除商品
const deleteProduct = async (product) => {
try {
await ElMessageBox.confirm(
`确定要删除商品 "${product.product_name}" `,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await axios.delete(`/admin/products/primary/${product.id}`)
ElMessage.success('商品删除成功')
getProducts()
} catch (error) {
if (error !== 'cancel') {
console.error('删除商品失败:', error)
}
}
}
// 组件挂载后获取数据
onMounted(() => {
getCategories()
getProducts()
})
</script>
<style scoped>
.primary-products-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
margin-bottom: -18px;
}
.table-card {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.text-gray-400 {
color: #a0a0a0;
}
.text-red-500 {
color: #ef4444;
}
.text-xs {
font-size: 12px;
}
.font-semibold {
font-weight: 600;
}
.line-through {
text-decoration: line-through;
}
.danger-text {
color: #f56c6c;
}
.danger-text:hover {
color: #f78989;
}
.dialog-footer {
text-align: right;
}
.image-gallery {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form .el-form-item {
width: 100%;
margin-right: 0;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
</style>
@@ -0,0 +1,435 @@
<template>
<div class="secondary-products-page">
<div class="page-header">
<h2>二级商品管理</h2>
<span class="page-desc">用户上架的商品仅支持查看</span>
</div>
<!-- 搜索筛选 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" :inline="true" class="search-form">
<el-form-item label="卖家ID">
<el-input
v-model="searchForm.seller_id"
placeholder="请输入卖家ID"
clearable
style="width: 150px"
/>
</el-form-item>
<el-form-item label="商品名称">
<el-input
v-model="searchForm.product_name"
placeholder="请输入商品名称"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 120px"
>
<el-option label="下架" value="0" />
<el-option label="上架" value="1" />
<el-option label="已售出" value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getProducts">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 商品列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="products"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="商品图片" width="100">
<template #default="{ row }">
<el-image
v-if="getProductImages(row.product_images)[0]"
:src="getImageUrl(getProductImages(row.product_images)[0])"
:preview-src-list="getProductImages(row.product_images).map(img => getImageUrl(img))"
fit="cover"
style="width: 60px; height: 60px; border-radius: 4px;"
/>
<span v-else class="text-gray-400">无图片</span>
</template>
</el-table-column>
<el-table-column prop="product_name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column label="卖家信息" width="140">
<template #default="{ row }">
<div>{{ row.seller?.customer_name || row.seller?.phone }}</div>
<div class="text-xs text-gray-500">ID: {{ row.seller_id }}</div>
</template>
</el-table-column>
<el-table-column label="原商品类型" width="120">
<template #default="{ row }">
<el-tag :type="row.original_product_type === 1 ? 'primary' : 'warning'" size="small">
{{ row.original_product_type === 1 ? '一级商品' : '二级商品' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="价格信息" width="150">
<template #default="{ row }">
<div>
<div class="text-green-600 font-semibold">售价: ¥{{ formatAmount(row.selling_price) }}</div>
<div class="text-gray-500 text-xs">成本: ¥{{ formatAmount(row.cost_price) }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="view_count" label="浏览量" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)" size="small">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="text" size="small" @click="showProductDetail(row)">
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getProducts"
@current-change="getProducts"
/>
</div>
</el-card>
<!-- 商品详情对话框 -->
<el-dialog
v-model="detailVisible"
title="二级商品详情"
width="800px"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="商品ID">{{ currentProduct.id }}</el-descriptions-item>
<el-descriptions-item label="商品名称">{{ currentProduct.product_name }}</el-descriptions-item>
<el-descriptions-item label="卖家姓名">{{ currentProduct.seller?.customer_name }}</el-descriptions-item>
<el-descriptions-item label="卖家手机">{{ currentProduct.seller?.phone }}</el-descriptions-item>
<el-descriptions-item label="原商品ID">{{ currentProduct.original_product_id }}</el-descriptions-item>
<el-descriptions-item label="原商品类型">
<el-tag :type="currentProduct.original_product_type === 1 ? 'primary' : 'warning'">
{{ currentProduct.original_product_type === 1 ? '一级商品' : '二级商品' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="成本价">¥{{ formatAmount(currentProduct.cost_price) }}</el-descriptions-item>
<el-descriptions-item label="售价">¥{{ formatAmount(currentProduct.selling_price) }}</el-descriptions-item>
<el-descriptions-item label="利润">
<span :class="getProfitClass(currentProduct.selling_price - currentProduct.cost_price)">
¥{{ formatAmount(currentProduct.selling_price - currentProduct.cost_price) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="数量">{{ currentProduct.quantity }}</el-descriptions-item>
<el-descriptions-item label="浏览量">{{ currentProduct.view_count }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTagType(currentProduct.status)">
{{ getStatusName(currentProduct.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="商品描述" :span="2">{{ currentProduct.product_description || '无' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(currentProduct.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(currentProduct.updated_at) }}</el-descriptions-item>
</el-descriptions>
<!-- 卖家信息 -->
<div v-if="currentProduct.seller" class="mt-4">
<h4>卖家信息</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="卖家ID">{{ currentProduct.seller.id }}</el-descriptions-item>
<el-descriptions-item label="卖家身份码">{{ currentProduct.seller.identity_code }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ currentProduct.seller.real_name }}</el-descriptions-item>
<el-descriptions-item label="当前积分">{{ currentProduct.seller.current_points }}</el-descriptions-item>
<el-descriptions-item label="公司名称" :span="2">{{ currentProduct.seller.company_name || '无' }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 商品图片 -->
<div v-if="getProductImages(currentProduct.product_images).length > 0" class="mt-4">
<h4>商品图片</h4>
<div class="image-gallery">
<el-image
v-for="(image, index) in getProductImages(currentProduct.product_images)"
:key="index"
:src="getImageUrl(image)"
:preview-src-list="getProductImages(currentProduct.product_images).map(img => getImageUrl(img))"
fit="cover"
class="product-image"
/>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import axios from 'axios'
import dayjs from 'dayjs'
// 响应式数据
const loading = ref(false)
const products = ref([])
const detailVisible = ref(false)
// 分页信息
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
seller_id: '',
product_name: '',
status: ''
})
// 当前查看的商品
const currentProduct = ref({})
// 获取商品列表
const getProducts = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
// 过滤空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await axios.get('/admin/products/secondary', { params })
if (response.data.success) {
products.value = response.data.data.list || []
pagination.total = response.data.data.total || 0
}
} catch (error) {
console.error('获取二级商品列表失败:', error)
} finally {
loading.value = false
}
}
// 重置搜索
const resetSearch = () => {
Object.assign(searchForm, {
seller_id: '',
product_name: '',
status: ''
})
pagination.page = 1
getProducts()
}
// 获取状态名称
const getStatusName = (status) => {
const statusMap = { 0: '下架', 1: '上架', 2: '已售出' }
return statusMap[status] || '未知'
}
// 获取状态标签类型
const getStatusTagType = (status) => {
const typeMap = { 0: 'danger', 1: 'success', 2: 'info' }
return typeMap[status] || 'info'
}
// 获取利润颜色类
const getProfitClass = (profit) => {
if (profit > 0) {
return 'text-green-600 font-semibold'
} else if (profit < 0) {
return 'text-red-600 font-semibold'
}
return 'text-gray-600'
}
// 获取图片完整URL
const getImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${window.location.origin}${url}`
}
// 获取商品图片列表
const getProductImages = (images) => {
if (!images) return []
return images.split(',').filter(img => img.trim())
}
// 格式化金额
const formatAmount = (amount) => {
if (!amount) return '0.00'
return Number(amount).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 显示商品详情
const showProductDetail = (product) => {
currentProduct.value = product
detailVisible.value = true
}
// 组件挂载后获取数据
onMounted(() => {
getProducts()
})
</script>
<style scoped>
.secondary-products-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
.page-desc {
color: #909399;
font-size: 14px;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
margin-bottom: -18px;
}
.table-card {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.text-gray-400 {
color: #a0a0a0;
}
.text-gray-500 {
color: #6b7280;
}
.text-gray-600 {
color: #4b5563;
}
.text-green-600 {
color: #059669;
}
.text-red-600 {
color: #dc2626;
}
.text-xs {
font-size: 12px;
}
.font-semibold {
font-weight: 600;
}
.image-gallery {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form .el-form-item {
width: 100%;
margin-right: 0;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style>
+17
View File
@@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}"
],
theme: {
extend: {
colors: {
primary: '#3B82F6' // 扩展主题色
}
}
},
plugins: []
}
+32
View File
@@ -0,0 +1,32 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: "localhost",
cors: true,
port: 1111,
open: false, //自动打开
proxy: {
// 这里的ccc可乱写, 是拼接在url后面的地址 如果接口中没有统一的后缀可自定义
// 如果有统一后缀, 如api, 直接写api即可, 也不用rewrite了
"^/back": {
target: "http://localhost:8090", // 真实接口地址, 后端给的基地址
changeOrigin: true, // 允许跨域
secure: false, //关键参数,不懂的自己去查
rewrite: (path) => path.replace(/^\/back/, '/back')
},
},
},
})