重构认证系统和订单支付功能,新增邮箱验证、密码重置及支付流程
This commit is contained in:
666
admin-system/src/components/AdvancedSearch.vue
Normal file
666
admin-system/src/components/AdvancedSearch.vue
Normal file
@@ -0,0 +1,666 @@
|
||||
<template>
|
||||
<div class="advanced-search">
|
||||
<a-card size="small">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<SearchOutlined />
|
||||
高级搜索
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button size="small" @click="handleReset">
|
||||
重置
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="toggleExpanded"
|
||||
:icon="expanded ? h(UpOutlined) : h(DownOutlined)"
|
||||
>
|
||||
{{ expanded ? '收起' : '展开' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-form
|
||||
:model="searchForm"
|
||||
layout="inline"
|
||||
@finish="handleSearch"
|
||||
class="search-form"
|
||||
>
|
||||
<!-- 基础搜索行 -->
|
||||
<div class="search-row basic-row">
|
||||
<a-form-item label="关键词" name="keyword">
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="请输入关键词"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select
|
||||
v-model:value="searchForm.status"
|
||||
placeholder="请选择状态"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
<a-tag :color="status.color" size="small">{{ status.label }}</a-tag>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="时间范围" name="dateRange">
|
||||
<a-range-picker
|
||||
v-model:value="searchForm.dateRange"
|
||||
style="width: 240px"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="searching">
|
||||
<SearchOutlined />
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 高级搜索行 -->
|
||||
<div v-show="expanded" class="search-row advanced-row">
|
||||
<!-- 用户相关字段 -->
|
||||
<template v-if="searchType === 'user'">
|
||||
<a-form-item label="用户类型" name="userType">
|
||||
<a-select
|
||||
v-model:value="searchForm.userType"
|
||||
placeholder="请选择用户类型"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="normal">普通用户</a-select-option>
|
||||
<a-select-option value="vip">VIP用户</a-select-option>
|
||||
<a-select-option value="admin">管理员</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="注册来源" name="registerSource">
|
||||
<a-select
|
||||
v-model:value="searchForm.registerSource"
|
||||
placeholder="请选择注册来源"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="web">网页端</a-select-option>
|
||||
<a-select-option value="wechat">微信小程序</a-select-option>
|
||||
<a-select-option value="app">移动应用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="年龄范围" name="ageRange">
|
||||
<a-slider
|
||||
v-model:value="searchForm.ageRange"
|
||||
range
|
||||
:min="0"
|
||||
:max="100"
|
||||
style="width: 200px"
|
||||
:tooltip-formatter="(value: number) => `${value}岁`"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="地区" name="region">
|
||||
<a-cascader
|
||||
v-model:value="searchForm.region"
|
||||
:options="regionOptions"
|
||||
placeholder="请选择地区"
|
||||
style="width: 200px"
|
||||
change-on-select
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 动物相关字段 -->
|
||||
<template v-if="searchType === 'animal'">
|
||||
<a-form-item label="动物类型" name="animalType">
|
||||
<a-select
|
||||
v-model:value="searchForm.animalType"
|
||||
placeholder="请选择动物类型"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="dog">狗</a-select-option>
|
||||
<a-select-option value="cat">猫</a-select-option>
|
||||
<a-select-option value="bird">鸟</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="品种" name="breed">
|
||||
<a-input
|
||||
v-model:value="searchForm.breed"
|
||||
placeholder="请输入品种"
|
||||
style="width: 150px"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="年龄范围" name="animalAgeRange">
|
||||
<a-input-group compact style="width: 200px">
|
||||
<a-input-number
|
||||
v-model:value="searchForm.minAge"
|
||||
placeholder="最小年龄"
|
||||
:min="0"
|
||||
:max="30"
|
||||
style="width: 50%"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="searchForm.maxAge"
|
||||
placeholder="最大年龄"
|
||||
:min="0"
|
||||
:max="30"
|
||||
style="width: 50%"
|
||||
/>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="性别" name="gender">
|
||||
<a-radio-group v-model:value="searchForm.gender">
|
||||
<a-radio value="male">雄性</a-radio>
|
||||
<a-radio value="female">雌性</a-radio>
|
||||
<a-radio value="unknown">未知</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="健康状态" name="healthStatus">
|
||||
<a-select
|
||||
v-model:value="searchForm.healthStatus"
|
||||
placeholder="请选择健康状态"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="healthy">健康</a-select-option>
|
||||
<a-select-option value="sick">生病</a-select-option>
|
||||
<a-select-option value="injured">受伤</a-select-option>
|
||||
<a-select-option value="recovering">康复中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 订单相关字段 -->
|
||||
<template v-if="searchType === 'order'">
|
||||
<a-form-item label="订单类型" name="orderType">
|
||||
<a-select
|
||||
v-model:value="searchForm.orderType"
|
||||
placeholder="请选择订单类型"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="adoption">认领</a-select-option>
|
||||
<a-select-option value="donation">捐赠</a-select-option>
|
||||
<a-select-option value="service">服务</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="金额范围" name="amountRange">
|
||||
<a-input-group compact style="width: 200px">
|
||||
<a-input-number
|
||||
v-model:value="searchForm.minAmount"
|
||||
placeholder="最小金额"
|
||||
:min="0"
|
||||
style="width: 50%"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="searchForm.maxAmount"
|
||||
placeholder="最大金额"
|
||||
:min="0"
|
||||
style="width: 50%"
|
||||
/>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="支付方式" name="paymentMethod">
|
||||
<a-select
|
||||
v-model:value="searchForm.paymentMethod"
|
||||
placeholder="请选择支付方式"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="wechat">微信支付</a-select-option>
|
||||
<a-select-option value="alipay">支付宝</a-select-option>
|
||||
<a-select-option value="bank">银行卡</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 通用高级字段 -->
|
||||
<a-form-item label="创建人" name="creator">
|
||||
<a-input
|
||||
v-model:value="searchForm.creator"
|
||||
placeholder="请输入创建人"
|
||||
style="width: 150px"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="排序方式" name="sortBy">
|
||||
<a-select
|
||||
v-model:value="searchForm.sortBy"
|
||||
placeholder="请选择排序方式"
|
||||
style="width: 150px"
|
||||
>
|
||||
<a-select-option value="created_at_desc">创建时间降序</a-select-option>
|
||||
<a-select-option value="created_at_asc">创建时间升序</a-select-option>
|
||||
<a-select-option value="updated_at_desc">更新时间降序</a-select-option>
|
||||
<a-select-option value="updated_at_asc">更新时间升序</a-select-option>
|
||||
<a-select-option value="name_asc">名称升序</a-select-option>
|
||||
<a-select-option value="name_desc">名称降序</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 快速筛选标签 -->
|
||||
<div class="quick-filters" v-if="quickFilters.length > 0">
|
||||
<a-divider orientation="left" orientation-margin="0">快速筛选</a-divider>
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="filter in quickFilters"
|
||||
:key="filter.key"
|
||||
:color="filter.active ? 'blue' : 'default'"
|
||||
style="cursor: pointer"
|
||||
@click="handleQuickFilter(filter)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div class="search-history" v-if="searchHistory.length > 0 && expanded">
|
||||
<a-divider orientation="left" orientation-margin="0">搜索历史</a-divider>
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="(history, index) in searchHistory.slice(0, 5)"
|
||||
:key="index"
|
||||
closable
|
||||
@close="removeSearchHistory(index)"
|
||||
@click="applySearchHistory(history)"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{{ history.keyword || '无关键词' }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, h } from 'vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
DownOutlined,
|
||||
UpOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
interface SearchForm {
|
||||
keyword?: string
|
||||
status?: string
|
||||
dateRange?: [string, string]
|
||||
userType?: string
|
||||
registerSource?: string
|
||||
ageRange?: [number, number]
|
||||
region?: string[]
|
||||
animalType?: string
|
||||
breed?: string
|
||||
minAge?: number
|
||||
maxAge?: number
|
||||
gender?: string
|
||||
healthStatus?: string
|
||||
orderType?: string
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
paymentMethod?: string
|
||||
creator?: string
|
||||
sortBy?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface StatusOption {
|
||||
value: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface QuickFilter {
|
||||
key: string
|
||||
label: string
|
||||
active: boolean
|
||||
params: Partial<SearchForm>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
searchType: 'user' | 'animal' | 'order' | 'travel'
|
||||
statusOptions?: StatusOption[]
|
||||
defaultValues?: Partial<SearchForm>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
statusOptions: () => [
|
||||
{ value: 'active', label: '激活', color: 'green' },
|
||||
{ value: 'inactive', label: '禁用', color: 'red' },
|
||||
{ value: 'pending', label: '待审核', color: 'orange' }
|
||||
]
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'search': [params: SearchForm]
|
||||
'reset': []
|
||||
}>()
|
||||
|
||||
// 响应式数据
|
||||
const expanded = ref(false)
|
||||
const searching = ref(false)
|
||||
|
||||
const searchForm = reactive<SearchForm>({
|
||||
keyword: '',
|
||||
status: undefined,
|
||||
dateRange: undefined,
|
||||
sortBy: 'created_at_desc',
|
||||
ageRange: [0, 100],
|
||||
...props.defaultValues
|
||||
})
|
||||
|
||||
// 地区选项(示例数据)
|
||||
const regionOptions = ref([
|
||||
{
|
||||
value: 'beijing',
|
||||
label: '北京市',
|
||||
children: [
|
||||
{ value: 'chaoyang', label: '朝阳区' },
|
||||
{ value: 'haidian', label: '海淀区' },
|
||||
{ value: 'dongcheng', label: '东城区' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'shanghai',
|
||||
label: '上海市',
|
||||
children: [
|
||||
{ value: 'huangpu', label: '黄浦区' },
|
||||
{ value: 'xuhui', label: '徐汇区' },
|
||||
{ value: 'changning', label: '长宁区' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 快速筛选
|
||||
const quickFilters = ref<QuickFilter[]>([])
|
||||
|
||||
// 搜索历史
|
||||
const searchHistory = ref<SearchForm[]>([])
|
||||
|
||||
// 初始化快速筛选
|
||||
const initQuickFilters = () => {
|
||||
const baseFilters: QuickFilter[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: '今日新增',
|
||||
active: false,
|
||||
params: {
|
||||
dateRange: [
|
||||
new Date().toISOString().split('T')[0],
|
||||
new Date().toISOString().split('T')[0]
|
||||
] as [string, string]
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'week',
|
||||
label: '本周新增',
|
||||
active: false,
|
||||
params: {
|
||||
dateRange: [
|
||||
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
new Date().toISOString().split('T')[0]
|
||||
] as [string, string]
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: '仅显示激活',
|
||||
active: false,
|
||||
params: { status: 'active' }
|
||||
}
|
||||
]
|
||||
|
||||
// 根据搜索类型添加特定筛选
|
||||
switch (props.searchType) {
|
||||
case 'user':
|
||||
baseFilters.push(
|
||||
{
|
||||
key: 'vip',
|
||||
label: 'VIP用户',
|
||||
active: false,
|
||||
params: { userType: 'vip' }
|
||||
},
|
||||
{
|
||||
key: 'wechat',
|
||||
label: '微信用户',
|
||||
active: false,
|
||||
params: { registerSource: 'wechat' }
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
case 'animal':
|
||||
baseFilters.push(
|
||||
{
|
||||
key: 'healthy',
|
||||
label: '健康动物',
|
||||
active: false,
|
||||
params: { healthStatus: 'healthy' }
|
||||
},
|
||||
{
|
||||
key: 'young',
|
||||
label: '幼年动物',
|
||||
active: false,
|
||||
params: { minAge: 0, maxAge: 2 }
|
||||
}
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
quickFilters.value = baseFilters
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换展开/收起
|
||||
*/
|
||||
const toggleExpanded = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行搜索
|
||||
*/
|
||||
const handleSearch = () => {
|
||||
searching.value = true
|
||||
|
||||
// 清理空值
|
||||
const cleanParams = Object.keys(searchForm).reduce((acc, key) => {
|
||||
const value = searchForm[key]
|
||||
if (value !== undefined && value !== null && value !== '' &&
|
||||
!(Array.isArray(value) && value.length === 0)) {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
}, {} as SearchForm)
|
||||
|
||||
// 保存搜索历史
|
||||
saveSearchHistory(cleanParams)
|
||||
|
||||
emit('search', cleanParams)
|
||||
|
||||
setTimeout(() => {
|
||||
searching.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置搜索
|
||||
*/
|
||||
const handleReset = () => {
|
||||
Object.keys(searchForm).forEach(key => {
|
||||
if (key === 'sortBy') {
|
||||
searchForm[key] = 'created_at_desc'
|
||||
} else if (key === 'ageRange') {
|
||||
searchForm[key] = [0, 100]
|
||||
} else {
|
||||
searchForm[key] = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// 重置快速筛选
|
||||
quickFilters.value.forEach(filter => {
|
||||
filter.active = false
|
||||
})
|
||||
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速筛选
|
||||
*/
|
||||
const handleQuickFilter = (filter: QuickFilter) => {
|
||||
filter.active = !filter.active
|
||||
|
||||
if (filter.active) {
|
||||
// 应用筛选参数
|
||||
Object.assign(searchForm, filter.params)
|
||||
} else {
|
||||
// 移除筛选参数
|
||||
Object.keys(filter.params).forEach(key => {
|
||||
searchForm[key] = undefined
|
||||
})
|
||||
}
|
||||
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存搜索历史
|
||||
*/
|
||||
const saveSearchHistory = (params: SearchForm) => {
|
||||
// 避免重复
|
||||
const exists = searchHistory.value.some(history =>
|
||||
JSON.stringify(history) === JSON.stringify(params)
|
||||
)
|
||||
|
||||
if (!exists) {
|
||||
searchHistory.value.unshift(params)
|
||||
// 最多保存10条历史
|
||||
if (searchHistory.value.length > 10) {
|
||||
searchHistory.value = searchHistory.value.slice(0, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用搜索历史
|
||||
*/
|
||||
const applySearchHistory = (history: SearchForm) => {
|
||||
Object.assign(searchForm, history)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除搜索历史
|
||||
*/
|
||||
const removeSearchHistory = (index: number) => {
|
||||
searchHistory.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
initQuickFilters()
|
||||
|
||||
// 监听搜索类型变化
|
||||
watch(() => props.searchType, () => {
|
||||
initQuickFilters()
|
||||
handleReset()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.advanced-search {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.basic-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.quick-filters,
|
||||
.search-history {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.quick-filters :deep(.ant-divider),
|
||||
.search-history :deep(.ant-divider) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
width: auto;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
455
admin-system/src/components/BatchOperations.vue
Normal file
455
admin-system/src/components/BatchOperations.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<div class="batch-operations">
|
||||
<a-card title="批量操作" size="small">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="handleSelectAll"
|
||||
:disabled="!dataSource.length"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="handleClearSelection"
|
||||
:disabled="!selectedItems.length"
|
||||
>
|
||||
清空选择
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<div class="batch-info" v-if="selectedItems.length > 0">
|
||||
<a-alert
|
||||
:message="`已选择 ${selectedItems.length} 项`"
|
||||
type="info"
|
||||
show-icon
|
||||
closable
|
||||
@close="handleClearSelection"
|
||||
>
|
||||
<template #action>
|
||||
<a-space>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<template #overlay>
|
||||
<a-menu @click="handleBatchAction">
|
||||
<a-menu-item
|
||||
v-for="action in availableActions"
|
||||
:key="action.key"
|
||||
:disabled="action.disabled"
|
||||
>
|
||||
<component :is="action.icon" v-if="action.icon" />
|
||||
{{ action.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="primary" size="small">
|
||||
批量操作
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 批量状态更新模态框 -->
|
||||
<a-modal
|
||||
v-model:open="statusModalVisible"
|
||||
title="批量状态更新"
|
||||
@ok="handleStatusUpdate"
|
||||
:confirm-loading="statusUpdateLoading"
|
||||
>
|
||||
<a-form :model="statusForm" layout="vertical">
|
||||
<a-form-item label="新状态" required>
|
||||
<a-select v-model:value="statusForm.status" placeholder="请选择状态">
|
||||
<a-select-option
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
<a-tag :color="status.color">{{ status.label }}</a-tag>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="操作原因">
|
||||
<a-textarea
|
||||
v-model:value="statusForm.reason"
|
||||
placeholder="请输入操作原因(可选)"
|
||||
:rows="3"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-alert
|
||||
:message="`将对 ${selectedItems.length} 个项目执行状态更新操作`"
|
||||
type="warning"
|
||||
show-icon
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 批量删除确认模态框 -->
|
||||
<a-modal
|
||||
v-model:open="deleteModalVisible"
|
||||
title="批量删除确认"
|
||||
@ok="handleBatchDelete"
|
||||
:confirm-loading="deleteLoading"
|
||||
ok-text="确认删除"
|
||||
ok-type="danger"
|
||||
>
|
||||
<a-alert
|
||||
message="危险操作"
|
||||
:description="`您即将删除 ${selectedItems.length} 个项目,此操作不可撤销!`"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div style="margin-top: 16px;">
|
||||
<a-checkbox v-model:checked="deleteConfirm">
|
||||
我确认要执行此删除操作
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 批量导出模态框 -->
|
||||
<a-modal
|
||||
v-model:open="exportModalVisible"
|
||||
title="批量导出"
|
||||
@ok="handleBatchExport"
|
||||
:confirm-loading="exportLoading"
|
||||
>
|
||||
<a-form :model="exportForm" layout="vertical">
|
||||
<a-form-item label="导出格式" required>
|
||||
<a-radio-group v-model:value="exportForm.format">
|
||||
<a-radio value="csv">CSV格式</a-radio>
|
||||
<a-radio value="excel">Excel格式</a-radio>
|
||||
<a-radio value="json">JSON格式</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="导出字段">
|
||||
<a-checkbox-group v-model:value="exportForm.fields">
|
||||
<a-row>
|
||||
<a-col :span="8" v-for="field in exportFields" :key="field.key">
|
||||
<a-checkbox :value="field.key">{{ field.label }}</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-alert
|
||||
:message="`将导出 ${selectedItems.length} 个项目的数据`"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
DownOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
SendOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
|
||||
interface BatchItem {
|
||||
id: number | string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface BatchAction {
|
||||
key: string
|
||||
label: string
|
||||
icon?: any
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface StatusOption {
|
||||
value: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface ExportField {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dataSource: BatchItem[]
|
||||
selectedItems: BatchItem[]
|
||||
operationType: 'user' | 'animal' | 'order' | 'travel'
|
||||
statusOptions?: StatusOption[]
|
||||
exportFields?: ExportField[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
statusOptions: () => [
|
||||
{ value: 'active', label: '激活', color: 'green' },
|
||||
{ value: 'inactive', label: '禁用', color: 'red' },
|
||||
{ value: 'pending', label: '待审核', color: 'orange' }
|
||||
],
|
||||
exportFields: () => [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'created_at', label: '创建时间' }
|
||||
]
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'selection-change': [items: BatchItem[]]
|
||||
'batch-action': [action: string, items: BatchItem[], params?: any]
|
||||
}>()
|
||||
|
||||
// 模态框状态
|
||||
const statusModalVisible = ref(false)
|
||||
const deleteModalVisible = ref(false)
|
||||
const exportModalVisible = ref(false)
|
||||
|
||||
// 加载状态
|
||||
const statusUpdateLoading = ref(false)
|
||||
const deleteLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const statusForm = ref({
|
||||
status: '',
|
||||
reason: ''
|
||||
})
|
||||
|
||||
const exportForm = ref({
|
||||
format: 'csv',
|
||||
fields: props.exportFields.map(f => f.key)
|
||||
})
|
||||
|
||||
const deleteConfirm = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const isAllSelected = computed(() => {
|
||||
return props.dataSource.length > 0 && props.selectedItems.length === props.dataSource.length
|
||||
})
|
||||
|
||||
const availableActions = computed((): BatchAction[] => {
|
||||
const baseActions: BatchAction[] = [
|
||||
{
|
||||
key: 'update-status',
|
||||
label: '更新状态',
|
||||
icon: EditOutlined
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: '导出数据',
|
||||
icon: DownloadOutlined
|
||||
}
|
||||
]
|
||||
|
||||
// 根据操作类型添加特定操作
|
||||
switch (props.operationType) {
|
||||
case 'user':
|
||||
baseActions.push(
|
||||
{
|
||||
key: 'send-message',
|
||||
label: '发送消息',
|
||||
icon: SendOutlined
|
||||
},
|
||||
{
|
||||
key: 'lock',
|
||||
label: '锁定账户',
|
||||
icon: LockOutlined
|
||||
},
|
||||
{
|
||||
key: 'unlock',
|
||||
label: '解锁账户',
|
||||
icon: UnlockOutlined
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
case 'animal':
|
||||
baseActions.push(
|
||||
{
|
||||
key: 'batch-approve',
|
||||
label: '批量审核',
|
||||
icon: EditOutlined
|
||||
}
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// 危险操作
|
||||
baseActions.push({
|
||||
key: 'delete',
|
||||
label: '批量删除',
|
||||
icon: DeleteOutlined,
|
||||
danger: true
|
||||
})
|
||||
|
||||
return baseActions
|
||||
})
|
||||
|
||||
/**
|
||||
* 全选/取消全选
|
||||
*/
|
||||
const handleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
emit('selection-change', [])
|
||||
} else {
|
||||
emit('selection-change', [...props.dataSource])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空选择
|
||||
*/
|
||||
const handleClearSelection = () => {
|
||||
emit('selection-change', [])
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量操作
|
||||
*/
|
||||
const handleBatchAction = ({ key }: { key: string }) => {
|
||||
if (!props.selectedItems.length) {
|
||||
message.warning('请先选择要操作的项目')
|
||||
return
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'update-status':
|
||||
statusModalVisible.value = true
|
||||
statusForm.value = { status: '', reason: '' }
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
deleteModalVisible.value = true
|
||||
deleteConfirm.value = false
|
||||
break
|
||||
|
||||
case 'export':
|
||||
exportModalVisible.value = true
|
||||
exportForm.value.fields = props.exportFields.map(f => f.key)
|
||||
break
|
||||
|
||||
default:
|
||||
// 直接执行其他操作
|
||||
emit('batch-action', key, props.selectedItems)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理状态更新
|
||||
*/
|
||||
const handleStatusUpdate = async () => {
|
||||
if (!statusForm.value.status) {
|
||||
message.error('请选择新状态')
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdateLoading.value = true
|
||||
|
||||
try {
|
||||
emit('batch-action', 'update-status', props.selectedItems, {
|
||||
status: statusForm.value.status,
|
||||
reason: statusForm.value.reason
|
||||
})
|
||||
|
||||
statusModalVisible.value = false
|
||||
message.success(`成功更新 ${props.selectedItems.length} 个项目的状态`)
|
||||
} catch (error) {
|
||||
message.error('批量状态更新失败')
|
||||
} finally {
|
||||
statusUpdateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量删除
|
||||
*/
|
||||
const handleBatchDelete = async () => {
|
||||
if (!deleteConfirm.value) {
|
||||
message.error('请确认删除操作')
|
||||
return
|
||||
}
|
||||
|
||||
deleteLoading.value = true
|
||||
|
||||
try {
|
||||
emit('batch-action', 'delete', props.selectedItems)
|
||||
|
||||
deleteModalVisible.value = false
|
||||
message.success(`成功删除 ${props.selectedItems.length} 个项目`)
|
||||
} catch (error) {
|
||||
message.error('批量删除失败')
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量导出
|
||||
*/
|
||||
const handleBatchExport = async () => {
|
||||
if (!exportForm.value.fields.length) {
|
||||
message.error('请选择要导出的字段')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
|
||||
try {
|
||||
emit('batch-action', 'export', props.selectedItems, {
|
||||
format: exportForm.value.format,
|
||||
fields: exportForm.value.fields
|
||||
})
|
||||
|
||||
exportModalVisible.value = false
|
||||
message.success(`开始导出 ${props.selectedItems.length} 个项目的数据`)
|
||||
} catch (error) {
|
||||
message.error('批量导出失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听选择变化
|
||||
watch(() => props.selectedItems, (newItems) => {
|
||||
// 可以在这里添加选择变化的逻辑
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.batch-operations {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.batch-info :deep(.ant-alert) {
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.batch-info :deep(.ant-alert-action) {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
342
admin-system/src/components/charts/DataStatisticsChart.vue
Normal file
342
admin-system/src/components/charts/DataStatisticsChart.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div class="data-statistics-chart">
|
||||
<a-card :title="title" :loading="loading">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="selectedPeriod"
|
||||
style="width: 120px"
|
||||
@change="handlePeriodChange"
|
||||
>
|
||||
<a-select-option value="7d">近7天</a-select-option>
|
||||
<a-select-option value="30d">近30天</a-select-option>
|
||||
<a-select-option value="90d">近90天</a-select-option>
|
||||
<a-select-option value="365d">近一年</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleRefresh" :loading="loading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<div ref="chartContainer" :style="{ height: chartHeight + 'px' }"></div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
interface ChartData {
|
||||
date: string
|
||||
value: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
chartType: 'line' | 'bar' | 'pie' | 'area'
|
||||
dataSource: string // API接口地址
|
||||
chartHeight?: number
|
||||
xAxisKey?: string
|
||||
yAxisKey?: string
|
||||
seriesConfig?: any[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
chartHeight: 300,
|
||||
xAxisKey: 'date',
|
||||
yAxisKey: 'value',
|
||||
seriesConfig: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
dataLoaded: [data: ChartData[]]
|
||||
error: [error: Error]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const selectedPeriod = ref('30d')
|
||||
const chartContainer = ref<HTMLDivElement>()
|
||||
let chartInstance: echarts.ECharts | null = null
|
||||
const chartData = ref<ChartData[]>([])
|
||||
|
||||
/**
|
||||
* 初始化图表
|
||||
*/
|
||||
const initChart = () => {
|
||||
if (!chartContainer.value) return
|
||||
|
||||
chartInstance = echarts.init(chartContainer.value)
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新图表配置
|
||||
*/
|
||||
const updateChart = () => {
|
||||
if (!chartInstance || !chartData.value.length) return
|
||||
|
||||
const option = generateChartOption()
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图表配置
|
||||
*/
|
||||
const generateChartOption = () => {
|
||||
const baseOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: props.seriesConfig.map(s => s.name) || ['数据']
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
title: '保存为图片'
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: '区域缩放',
|
||||
back: '区域缩放还原'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据图表类型生成不同配置
|
||||
switch (props.chartType) {
|
||||
case 'line':
|
||||
case 'area':
|
||||
return {
|
||||
...baseOption,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: chartData.value.map(item => item[props.xAxisKey])
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: props.seriesConfig.length > 0
|
||||
? props.seriesConfig.map(config => ({
|
||||
...config,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: props.chartType === 'area' ? {} : undefined,
|
||||
data: chartData.value.map(item => item[config.dataKey || props.yAxisKey])
|
||||
}))
|
||||
: [{
|
||||
name: '数据',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: props.chartType === 'area' ? {} : undefined,
|
||||
data: chartData.value.map(item => item[props.yAxisKey])
|
||||
}]
|
||||
}
|
||||
|
||||
case 'bar':
|
||||
return {
|
||||
...baseOption,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.value.map(item => item[props.xAxisKey])
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: props.seriesConfig.length > 0
|
||||
? props.seriesConfig.map(config => ({
|
||||
...config,
|
||||
type: 'bar',
|
||||
data: chartData.value.map(item => item[config.dataKey || props.yAxisKey])
|
||||
}))
|
||||
: [{
|
||||
name: '数据',
|
||||
type: 'bar',
|
||||
data: chartData.value.map(item => item[props.yAxisKey])
|
||||
}]
|
||||
}
|
||||
|
||||
case 'pie':
|
||||
return {
|
||||
...baseOption,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
series: [{
|
||||
name: props.title,
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: chartData.value.map(item => ({
|
||||
name: item[props.xAxisKey],
|
||||
value: item[props.yAxisKey]
|
||||
})),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
default:
|
||||
return baseOption
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图表数据
|
||||
*/
|
||||
const loadData = async () => {
|
||||
if (!props.dataSource) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 这里应该调用实际的API接口
|
||||
// const response = await fetch(`${props.dataSource}?period=${selectedPeriod.value}`)
|
||||
// const result = await response.json()
|
||||
|
||||
// 模拟数据加载
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 生成模拟数据
|
||||
const mockData = generateMockData()
|
||||
chartData.value = mockData
|
||||
|
||||
emit('dataLoaded', mockData)
|
||||
|
||||
// 更新图表
|
||||
nextTick(() => {
|
||||
updateChart()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载图表数据失败:', error)
|
||||
message.error('加载图表数据失败')
|
||||
emit('error', error as Error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模拟数据
|
||||
*/
|
||||
const generateMockData = (): ChartData[] => {
|
||||
const days = selectedPeriod.value === '7d' ? 7 :
|
||||
selectedPeriod.value === '30d' ? 30 :
|
||||
selectedPeriod.value === '90d' ? 90 : 365
|
||||
|
||||
const data: ChartData[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i)
|
||||
|
||||
data.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
value: Math.floor(Math.random() * 100) + 50,
|
||||
users: Math.floor(Math.random() * 50) + 20,
|
||||
orders: Math.floor(Math.random() * 30) + 10,
|
||||
revenue: Math.floor(Math.random() * 5000) + 1000
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理时间周期变化
|
||||
*/
|
||||
const handlePeriodChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化
|
||||
*/
|
||||
const handleResize = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听数据变化
|
||||
*/
|
||||
watch(() => props.dataSource, () => {
|
||||
loadData()
|
||||
}, { immediate: false })
|
||||
|
||||
/**
|
||||
* 组件挂载
|
||||
*/
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
loadData()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 组件卸载
|
||||
*/
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refresh: handleRefresh,
|
||||
updateData: (data: ChartData[]) => {
|
||||
chartData.value = data
|
||||
updateChart()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-statistics-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-statistics-chart :deep(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
219
admin-system/src/pages/animals/components/AnimalDetail.vue
Normal file
219
admin-system/src/pages/animals/components/AnimalDetail.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="动物详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div v-if="animal" class="animal-detail">
|
||||
<!-- 基本信息 -->
|
||||
<a-card title="基本信息" class="mb-4">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<div class="animal-avatar">
|
||||
<a-avatar
|
||||
:src="animal.avatar"
|
||||
:alt="animal.name"
|
||||
:size="120"
|
||||
shape="square"
|
||||
>
|
||||
{{ animal.name?.charAt(0) }}
|
||||
</a-avatar>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="18">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>动物ID:</label>
|
||||
<span>{{ animal.id }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>名称:</label>
|
||||
<span>{{ animal.name }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>类型:</label>
|
||||
<a-tag color="blue">{{ animal.type }}</a-tag>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>品种:</label>
|
||||
<span>{{ animal.breed }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>年龄:</label>
|
||||
<span>{{ animal.age }}岁</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>性别:</label>
|
||||
<span>{{ animal.gender }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>颜色:</label>
|
||||
<span>{{ animal.color }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>状态:</label>
|
||||
<a-tag :color="getStatusColor(animal.status)">
|
||||
{{ getStatusText(animal.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>健康状态:</label>
|
||||
<span>{{ animal.health_status }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 详细描述 -->
|
||||
<a-card title="详细描述" class="mb-4">
|
||||
<p>{{ animal.description || '暂无描述' }}</p>
|
||||
</a-card>
|
||||
|
||||
<!-- 位置信息 -->
|
||||
<a-card title="位置信息" class="mb-4">
|
||||
<div class="detail-item">
|
||||
<label>当前位置:</label>
|
||||
<span>{{ animal.location }}</span>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<a-card title="时间信息">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>创建时间:</label>
|
||||
<span>{{ formatDate(animal.createdAt) }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>更新时间:</label>
|
||||
<span>{{ formatDate(animal.updatedAt) }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Animal {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
breed: string
|
||||
age: number
|
||||
gender: string
|
||||
color: string
|
||||
avatar: string
|
||||
description: string
|
||||
status: 'available' | 'adopted' | 'unavailable'
|
||||
health_status: string
|
||||
location: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
animal: Animal | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
available: 'green',
|
||||
adopted: 'blue',
|
||||
unavailable: 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
available: '可领养',
|
||||
adopted: '已领养',
|
||||
unavailable: '不可领养'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animal-detail {
|
||||
.animal-avatar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 12px;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
362
admin-system/src/pages/animals/components/AnimalForm.vue
Normal file
362
admin-system/src/pages/animals/components/AnimalForm.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="isEditing ? '编辑动物' : '新增动物'"
|
||||
width="800px"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="动物名称" name="name">
|
||||
<a-input
|
||||
v-model:value="formData.name"
|
||||
placeholder="请输入动物名称"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="动物类型" name="type">
|
||||
<a-select
|
||||
v-model:value="formData.type"
|
||||
placeholder="请选择动物类型"
|
||||
>
|
||||
<a-select-option value="狗">狗</a-select-option>
|
||||
<a-select-option value="猫">猫</a-select-option>
|
||||
<a-select-option value="兔子">兔子</a-select-option>
|
||||
<a-select-option value="鸟类">鸟类</a-select-option>
|
||||
<a-select-option value="其他">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="品种" name="breed">
|
||||
<a-input
|
||||
v-model:value="formData.breed"
|
||||
placeholder="请输入品种"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="年龄" name="age">
|
||||
<a-input-number
|
||||
v-model:value="formData.age"
|
||||
placeholder="请输入年龄"
|
||||
:min="0"
|
||||
:max="30"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="性别" name="gender">
|
||||
<a-select
|
||||
v-model:value="formData.gender"
|
||||
placeholder="请选择性别"
|
||||
>
|
||||
<a-select-option value="雄性">雄性</a-select-option>
|
||||
<a-select-option value="雌性">雌性</a-select-option>
|
||||
<a-select-option value="未知">未知</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="颜色" name="color">
|
||||
<a-input
|
||||
v-model:value="formData.color"
|
||||
placeholder="请输入颜色"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select
|
||||
v-model:value="formData.status"
|
||||
placeholder="请选择状态"
|
||||
>
|
||||
<a-select-option value="available">可领养</a-select-option>
|
||||
<a-select-option value="adopted">已领养</a-select-option>
|
||||
<a-select-option value="unavailable">不可领养</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="健康状态" name="health_status">
|
||||
<a-select
|
||||
v-model:value="formData.health_status"
|
||||
placeholder="请选择健康状态"
|
||||
>
|
||||
<a-select-option value="健康">健康</a-select-option>
|
||||
<a-select-option value="轻微疾病">轻微疾病</a-select-option>
|
||||
<a-select-option value="需要治疗">需要治疗</a-select-option>
|
||||
<a-select-option value="康复中">康复中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="位置" name="location">
|
||||
<a-input
|
||||
v-model:value="formData.location"
|
||||
placeholder="请输入当前位置"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="头像" name="avatar">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
name="avatar"
|
||||
list-type="picture-card"
|
||||
class="avatar-uploader"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
@change="handleChange"
|
||||
>
|
||||
<div v-if="formData.avatar">
|
||||
<img :src="formData.avatar" alt="avatar" style="width: 100%" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px">上传头像</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formData.description"
|
||||
placeholder="请输入动物描述"
|
||||
:rows="4"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { FormInstance, UploadChangeParam } from 'ant-design-vue'
|
||||
|
||||
interface Animal {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
breed: string
|
||||
age: number
|
||||
gender: string
|
||||
color: string
|
||||
avatar: string
|
||||
description: string
|
||||
status: 'available' | 'adopted' | 'unavailable'
|
||||
health_status: string
|
||||
location: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
animal: Animal | null
|
||||
visible: boolean
|
||||
isEditing: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'submit', data: any): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const fileList = ref([])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
type: '',
|
||||
breed: '',
|
||||
age: 0,
|
||||
gender: '',
|
||||
color: '',
|
||||
status: 'available' as 'available' | 'adopted' | 'unavailable',
|
||||
health_status: '健康',
|
||||
location: '',
|
||||
avatar: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules: Record<string, any[]> = {
|
||||
name: [
|
||||
{ required: true, message: '请输入动物名称', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '名称长度为1-50个字符', trigger: 'blur' }
|
||||
],
|
||||
type: [
|
||||
{ required: true, message: '请选择动物类型', trigger: 'change' }
|
||||
],
|
||||
breed: [
|
||||
{ required: true, message: '请输入品种', trigger: 'blur' }
|
||||
],
|
||||
age: [
|
||||
{ required: true, message: '请输入年龄', trigger: 'blur' },
|
||||
{ type: 'number', min: 0, max: 30, message: '年龄必须在0-30之间', trigger: 'blur' }
|
||||
],
|
||||
gender: [
|
||||
{ required: true, message: '请选择性别', trigger: 'change' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||
],
|
||||
location: [
|
||||
{ required: true, message: '请输入位置', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 监听动物数据变化,初始化表单
|
||||
watch(() => props.animal, (animal) => {
|
||||
if (animal && props.isEditing) {
|
||||
formData.name = animal.name
|
||||
formData.type = animal.type
|
||||
formData.breed = animal.breed
|
||||
formData.age = animal.age
|
||||
formData.gender = animal.gender
|
||||
formData.color = animal.color
|
||||
formData.status = animal.status
|
||||
formData.health_status = animal.health_status
|
||||
formData.location = animal.location
|
||||
formData.avatar = animal.avatar
|
||||
formData.description = animal.description
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听弹窗显示状态,重置表单
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible && !props.isEditing) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
type: '',
|
||||
breed: '',
|
||||
age: 0,
|
||||
gender: '',
|
||||
color: '',
|
||||
status: 'available',
|
||||
health_status: '健康',
|
||||
location: '',
|
||||
avatar: '',
|
||||
description: ''
|
||||
})
|
||||
fileList.value = []
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 文件上传前验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
if (!isJpgOrPng) {
|
||||
message.error('只能上传 JPG/PNG 格式的图片!')
|
||||
return false
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
if (!isLt2M) {
|
||||
message.error('图片大小不能超过 2MB!')
|
||||
return false
|
||||
}
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
// 文件上传变化处理
|
||||
const handleChange = (info: UploadChangeParam) => {
|
||||
if (info.file.originFileObj) {
|
||||
// 创建预览URL
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
formData.avatar = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(info.file.originFileObj)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
const submitData = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
breed: formData.breed,
|
||||
age: formData.age,
|
||||
gender: formData.gender,
|
||||
color: formData.color,
|
||||
status: formData.status,
|
||||
health_status: formData.health_status,
|
||||
location: formData.location,
|
||||
avatar: formData.avatar,
|
||||
description: formData.description
|
||||
}
|
||||
|
||||
emit('submit', submitData)
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
:deep(.ant-upload) {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
:deep(.ant-upload-select-picture-card) {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
716
admin-system/src/pages/animals/index.vue
Normal file
716
admin-system/src/pages/animals/index.vue
Normal file
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<div class="animals-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>动物管理</h1>
|
||||
<p>管理平台上的所有动物信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 数据统计 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总动物数"
|
||||
:value="statistics.totalAnimals"
|
||||
:value-style="{ color: '#3f8600' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<HeartOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="可领养"
|
||||
:value="statistics.availableAnimals"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<SmileOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="已领养"
|
||||
:value="statistics.claimedAnimals"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<HomeOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="今日新增"
|
||||
:value="statistics.newAnimalsToday"
|
||||
:value-style="{ color: '#cf1322' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 高级搜索 -->
|
||||
<AdvancedSearch
|
||||
search-type="animal"
|
||||
:status-options="statusOptions"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<BatchOperations
|
||||
:data-source="animals as any[]"
|
||||
:selected-items="selectedAnimals as any[]"
|
||||
operation-type="animal"
|
||||
:status-options="statusOptionsWithColor"
|
||||
:export-fields="exportFields"
|
||||
@selection-change="(items: any[]) => handleSelectionChange(items as Animal[])"
|
||||
@batch-action="(action: string, items: any[], params?: any) => handleBatchAction(action, items as Animal[], params)"
|
||||
/>
|
||||
|
||||
<!-- 动物列表表格 -->
|
||||
<a-card class="table-card">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="animals"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 1200 }"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 动物图片 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar
|
||||
:src="record.avatar"
|
||||
:alt="record.name"
|
||||
size="large"
|
||||
shape="square"
|
||||
>
|
||||
{{ record.name?.charAt(0) }}
|
||||
</a-avatar>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 动物类型 -->
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag color="blue">{{ record.type }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 年龄 -->
|
||||
<template v-else-if="column.key === 'age'">
|
||||
{{ record.age }}岁
|
||||
</template>
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ formatDate(record.createdAt) }}
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewAnimal(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEditAnimal(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button type="link" size="small">
|
||||
更多 <DownOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
key="activate"
|
||||
v-if="record.status !== 'available'"
|
||||
@click="handleUpdateStatus(record, 'available')"
|
||||
>
|
||||
<CheckCircleOutlined />
|
||||
设为可领养
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="deactivate"
|
||||
v-if="record.status !== 'unavailable'"
|
||||
@click="handleUpdateStatus(record, 'unavailable')"
|
||||
>
|
||||
<LockOutlined />
|
||||
设为不可领养
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" @click="handleDeleteAnimal(record)">
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 动物详情弹窗 -->
|
||||
<AnimalDetail
|
||||
:animal="currentAnimal"
|
||||
:visible="showAnimalDetail"
|
||||
@update:visible="showAnimalDetail = $event"
|
||||
/>
|
||||
|
||||
<!-- 动物表单弹窗 -->
|
||||
<AnimalForm
|
||||
:animal="currentAnimal"
|
||||
:visible="showAnimalForm"
|
||||
:is-editing="isEditing"
|
||||
@update:visible="showAnimalForm = $event"
|
||||
@submit="handleAnimalSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { SearchOutlined, PlusOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import type { Animal } from '@/api/animal'
|
||||
import animalAPI from '@/api/animal'
|
||||
import AdvancedSearch from '@/components/AdvancedSearch.vue'
|
||||
import BatchOperations from '@/components/BatchOperations.vue'
|
||||
import AnimalForm from './components/AnimalForm.vue'
|
||||
import AnimalDetail from './components/AnimalDetail.vue'
|
||||
|
||||
// 移除重复的Animal接口定义,使用api中的接口
|
||||
|
||||
interface Statistics {
|
||||
totalAnimals: number
|
||||
availableAnimals: number
|
||||
newAnimalsToday: number
|
||||
claimedAnimals: number
|
||||
}
|
||||
|
||||
// 临时格式化函数,直到utils/date模块可用
|
||||
const formatDate = (date: string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const animals = ref<Animal[]>([])
|
||||
const selectedAnimals = ref<Animal[]>([])
|
||||
const currentAnimal = ref<Animal | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const selectedRowKeys = computed(() => selectedAnimals.value.map(animal => animal.id))
|
||||
|
||||
// 统计数据
|
||||
const statistics = reactive<Statistics>({
|
||||
totalAnimals: 0,
|
||||
availableAnimals: 0,
|
||||
newAnimalsToday: 0,
|
||||
claimedAnimals: 0
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number, range: [number, number]) =>
|
||||
`第 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
})
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive({
|
||||
keyword: '',
|
||||
type: '',
|
||||
status: '',
|
||||
age_range: [] as number[],
|
||||
date_range: [] as string[]
|
||||
})
|
||||
|
||||
// 模态框状态
|
||||
const showAnimalDetail = ref(false)
|
||||
const showAnimalForm = ref(false)
|
||||
const isEditing = ref(false)
|
||||
|
||||
// 搜索字段配置
|
||||
const searchFields = [
|
||||
{
|
||||
key: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入动物名称或描述'
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: '动物类型',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '狗', value: '狗' },
|
||||
{ label: '猫', value: '猫' },
|
||||
{ label: '兔子', value: '兔子' },
|
||||
{ label: '鸟类', value: '鸟类' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '可领养', value: 'available' },
|
||||
{ label: '已领养', value: 'adopted' },
|
||||
{ label: '不可领养', value: 'unavailable' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 状态选项
|
||||
const statusOptions = [
|
||||
{ label: '可领养', value: 'available', color: 'green' },
|
||||
{ label: '已领养', value: 'adopted', color: 'blue' },
|
||||
{ label: '不可领养', value: 'unavailable', color: 'red' }
|
||||
]
|
||||
|
||||
// 批量操作用的状态选项
|
||||
const statusOptionsWithColor = [
|
||||
{ label: '可领养', value: 'available', color: 'green' },
|
||||
{ label: '已领养', value: 'adopted', color: 'blue' },
|
||||
{ label: '不可领养', value: 'unavailable', color: 'red' }
|
||||
]
|
||||
|
||||
// 导出字段
|
||||
const exportFields = [
|
||||
{ key: 'name', label: '动物名称' },
|
||||
{ key: 'species', label: '物种' },
|
||||
{ key: 'breed', label: '品种' },
|
||||
{ key: 'age', label: '年龄' },
|
||||
{ key: 'gender', label: '性别' },
|
||||
{ key: 'price', label: '价格' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'merchant_name', label: '商家' },
|
||||
{ key: 'created_at', label: '创建时间' }
|
||||
]
|
||||
|
||||
// 表格列配置
|
||||
const columns: TableColumnsType<Animal> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '图片',
|
||||
dataIndex: 'image',
|
||||
key: 'image',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '物种',
|
||||
dataIndex: 'species',
|
||||
key: 'species',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '品种',
|
||||
dataIndex: 'breed',
|
||||
key: 'breed',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '年龄',
|
||||
dataIndex: 'age',
|
||||
key: 'age',
|
||||
width: 80,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '性别',
|
||||
dataIndex: 'gender',
|
||||
key: 'gender',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: 100,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '商家',
|
||||
dataIndex: 'merchant_name',
|
||||
key: 'merchant_name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 160,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 表格行选择配置
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedAnimals.value.map(item => item.id),
|
||||
onChange: (selectedRowKeys: any[]) => {
|
||||
selectedAnimals.value = animals.value.filter(item =>
|
||||
selectedRowKeys.includes(item.id)
|
||||
)
|
||||
}
|
||||
})) as any
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
available: 'green',
|
||||
adopted: 'blue',
|
||||
unavailable: 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
available: '可领养',
|
||||
adopted: '已领养',
|
||||
unavailable: '不可领养'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 获取动物列表
|
||||
const fetchAnimals = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
// 模拟数据 - 使用类型断言避免类型检查
|
||||
const mockAnimals = [
|
||||
{
|
||||
id: 1,
|
||||
name: '小白',
|
||||
species: '狗',
|
||||
breed: '金毛',
|
||||
age: 2,
|
||||
gender: '雄性',
|
||||
description: '温顺可爱的金毛犬,性格友善,适合家庭饲养',
|
||||
image: 'https://example.com/dog1.jpg',
|
||||
merchant_id: 1,
|
||||
merchant_name: '爱心宠物店',
|
||||
price: 1500,
|
||||
status: 'available',
|
||||
created_at: '2024-01-15T10:30:00Z',
|
||||
updated_at: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '小花',
|
||||
species: '猫',
|
||||
breed: '英短',
|
||||
age: 1,
|
||||
gender: '雌性',
|
||||
description: '活泼可爱的英短猫,毛色纯正,健康活泼',
|
||||
image: 'https://example.com/cat1.jpg',
|
||||
merchant_id: 2,
|
||||
merchant_name: '温馨宠物之家',
|
||||
price: 2000,
|
||||
status: 'adopted',
|
||||
created_at: '2024-01-14T14:20:00Z',
|
||||
updated_at: '2024-01-16T09:15:00Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '小黑',
|
||||
species: '狗',
|
||||
breed: '拉布拉多',
|
||||
age: 3,
|
||||
gender: '雄性',
|
||||
description: '聪明忠诚的拉布拉多,训练有素,适合陪伴',
|
||||
image: 'https://example.com/dog2.jpg',
|
||||
merchant_id: 1,
|
||||
merchant_name: '爱心宠物店',
|
||||
price: 1800,
|
||||
status: 'available',
|
||||
created_at: '2024-01-13T16:45:00Z',
|
||||
updated_at: '2024-01-13T16:45:00Z'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '咪咪',
|
||||
species: '猫',
|
||||
breed: '波斯猫',
|
||||
age: 2,
|
||||
gender: '雌性',
|
||||
description: '优雅的波斯猫,毛发柔顺,性格温和',
|
||||
image: 'https://example.com/cat2.jpg',
|
||||
merchant_id: 3,
|
||||
merchant_name: '宠物乐园',
|
||||
price: 2500,
|
||||
status: 'unavailable',
|
||||
created_at: '2024-01-12T11:20:00Z',
|
||||
updated_at: '2024-01-17T14:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '小灰',
|
||||
species: '兔子',
|
||||
breed: '荷兰兔',
|
||||
age: 1,
|
||||
gender: '雄性',
|
||||
description: '可爱的荷兰兔,毛色灰白相间,性格活泼',
|
||||
image: 'https://example.com/rabbit1.jpg',
|
||||
merchant_id: 2,
|
||||
merchant_name: '温馨宠物之家',
|
||||
price: 800,
|
||||
status: 'available',
|
||||
created_at: '2024-01-16T09:10:00Z',
|
||||
updated_at: '2024-01-16T09:10:00Z'
|
||||
}
|
||||
] as Animal[]
|
||||
|
||||
const mockData = {
|
||||
list: mockAnimals,
|
||||
total: mockAnimals.length,
|
||||
statistics: {
|
||||
totalAnimals: 156,
|
||||
availableAnimals: 89,
|
||||
newAnimalsToday: 3,
|
||||
claimedAnimals: 45
|
||||
}
|
||||
}
|
||||
|
||||
animals.value = mockData.list
|
||||
pagination.total = mockData.total
|
||||
|
||||
// 更新统计数据
|
||||
Object.assign(statistics, mockData.statistics)
|
||||
} catch (error) {
|
||||
message.error('获取动物列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = (params: any) => {
|
||||
Object.assign(searchParams, params)
|
||||
pagination.current = 1
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
Object.assign(searchParams, {
|
||||
keyword: '',
|
||||
type: '',
|
||||
status: '',
|
||||
age_range: [],
|
||||
date_range: []
|
||||
})
|
||||
pagination.current = 1
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const handleRefresh = () => {
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 查看动物详情
|
||||
const handleViewAnimal = (animal: Animal) => {
|
||||
currentAnimal.value = animal
|
||||
showAnimalDetail.value = true
|
||||
}
|
||||
|
||||
// 编辑动物
|
||||
const handleEditAnimal = (animal: Animal) => {
|
||||
currentAnimal.value = animal
|
||||
isEditing.value = true
|
||||
showAnimalForm.value = true
|
||||
}
|
||||
|
||||
// 新增动物
|
||||
const handleAddAnimal = () => {
|
||||
currentAnimal.value = null
|
||||
isEditing.value = false
|
||||
showAnimalForm.value = true
|
||||
}
|
||||
|
||||
// 删除动物
|
||||
const handleDeleteAnimal = (animal: Animal) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除动物 "${animal.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
// 模拟API调用
|
||||
message.success('删除成功')
|
||||
fetchAnimals()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新动物状态
|
||||
const handleUpdateStatus = async (animal: Animal, status: string) => {
|
||||
try {
|
||||
// 模拟API调用
|
||||
message.success('状态更新成功')
|
||||
fetchAnimals()
|
||||
} catch (error) {
|
||||
message.error('状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量选择处理
|
||||
const handleSelectionChange = (items: Animal[]) => {
|
||||
selectedAnimals.value = items
|
||||
}
|
||||
|
||||
// 批量操作处理
|
||||
const handleBatchAction = async (action: string, items: Animal[], params?: any) => {
|
||||
try {
|
||||
const animalIds = items.map(animal => animal.id)
|
||||
|
||||
switch (action) {
|
||||
case 'updateStatus':
|
||||
message.success(`批量${params?.status === 'available' ? '设为可领养' : '更新状态'}成功`)
|
||||
break
|
||||
case 'delete':
|
||||
message.success('批量删除成功')
|
||||
break
|
||||
case 'export':
|
||||
message.success('导出成功')
|
||||
break
|
||||
default:
|
||||
message.warning('未知操作')
|
||||
return
|
||||
}
|
||||
|
||||
selectedAnimals.value = []
|
||||
fetchAnimals()
|
||||
} catch (error) {
|
||||
message.error('批量操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag: any, filters: any, sorter: any) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
|
||||
// TODO: 处理排序和筛选
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 动物表单提交
|
||||
const handleAnimalSubmit = async (animalData: any) => {
|
||||
try {
|
||||
if (isEditing.value && currentAnimal.value) {
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
message.success('创建成功')
|
||||
}
|
||||
showAnimalForm.value = false
|
||||
fetchAnimals()
|
||||
} catch (error) {
|
||||
message.error(isEditing.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchAnimals()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animals-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.statistics-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
577
admin-system/src/pages/statistics/index.vue
Normal file
577
admin-system/src/pages/statistics/index.vue
Normal file
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="statistics-page">
|
||||
<a-page-header
|
||||
title="数据统计"
|
||||
sub-title="系统数据分析与统计报表"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
@change="handleDateRangeChange"
|
||||
/>
|
||||
<a-button @click="handleExport" :loading="exportLoading">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
导出报表
|
||||
</a-button>
|
||||
<a-button @click="handleRefreshAll" :loading="refreshLoading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新全部
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 概览统计卡片 -->
|
||||
<a-row :gutter="16" class="overview-cards">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总用户数"
|
||||
:value="overviewData.totalUsers"
|
||||
:precision="0"
|
||||
suffix="人"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined style="color: #1890ff" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
<div class="statistic-trend">
|
||||
<span :class="['trend', overviewData.userGrowth >= 0 ? 'up' : 'down']">
|
||||
<CaretUpOutlined v-if="overviewData.userGrowth >= 0" />
|
||||
<CaretDownOutlined v-else />
|
||||
{{ Math.abs(overviewData.userGrowth) }}%
|
||||
</span>
|
||||
<span class="trend-text">较昨日</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="活跃用户"
|
||||
:value="overviewData.activeUsers"
|
||||
:precision="0"
|
||||
suffix="人"
|
||||
>
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined style="color: #52c41a" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
<div class="statistic-trend">
|
||||
<span :class="['trend', overviewData.activeGrowth >= 0 ? 'up' : 'down']">
|
||||
<CaretUpOutlined v-if="overviewData.activeGrowth >= 0" />
|
||||
<CaretDownOutlined v-else />
|
||||
{{ Math.abs(overviewData.activeGrowth) }}%
|
||||
</span>
|
||||
<span class="trend-text">较昨日</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="动物认领"
|
||||
:value="overviewData.totalAnimals"
|
||||
:precision="0"
|
||||
suffix="只"
|
||||
>
|
||||
<template #prefix>
|
||||
<HeartOutlined style="color: #eb2f96" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
<div class="statistic-trend">
|
||||
<span :class="['trend', overviewData.animalGrowth >= 0 ? 'up' : 'down']">
|
||||
<CaretUpOutlined v-if="overviewData.animalGrowth >= 0" />
|
||||
<CaretDownOutlined v-else />
|
||||
{{ Math.abs(overviewData.animalGrowth) }}%
|
||||
</span>
|
||||
<span class="trend-text">较昨日</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总收入"
|
||||
:value="overviewData.totalRevenue"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
>
|
||||
<template #prefix>
|
||||
<DollarOutlined style="color: #faad14" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
<div class="statistic-trend">
|
||||
<span :class="['trend', overviewData.revenueGrowth >= 0 ? 'up' : 'down']">
|
||||
<CaretUpOutlined v-if="overviewData.revenueGrowth >= 0" />
|
||||
<CaretDownOutlined v-else />
|
||||
{{ Math.abs(overviewData.revenueGrowth) }}%
|
||||
</span>
|
||||
<span class="trend-text">较昨日</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<a-row :gutter="16" class="chart-section">
|
||||
<a-col :span="12">
|
||||
<DataStatisticsChart
|
||||
ref="userGrowthChart"
|
||||
title="用户增长趋势"
|
||||
chart-type="area"
|
||||
data-source="/api/v1/admin/statistics/user-growth"
|
||||
:series-config="[
|
||||
{ name: '新增用户', dataKey: 'new_users', color: '#1890ff' },
|
||||
{ name: '累计用户', dataKey: 'cumulative_users', color: '#52c41a' }
|
||||
]"
|
||||
@data-loaded="handleUserGrowthLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<DataStatisticsChart
|
||||
ref="businessChart"
|
||||
title="业务数据统计"
|
||||
chart-type="line"
|
||||
data-source="/api/v1/admin/statistics/business"
|
||||
:series-config="[
|
||||
{ name: '动物认领', dataKey: 'animals', color: '#eb2f96' },
|
||||
{ name: '旅行计划', dataKey: 'travels', color: '#722ed1' },
|
||||
{ name: '订单数量', dataKey: 'orders', color: '#fa8c16' }
|
||||
]"
|
||||
@data-loaded="handleBusinessDataLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" class="chart-section">
|
||||
<a-col :span="8">
|
||||
<DataStatisticsChart
|
||||
ref="userTypeChart"
|
||||
title="用户类型分布"
|
||||
chart-type="pie"
|
||||
data-source="/api/v1/admin/statistics/user-types"
|
||||
@data-loaded="handleUserTypeLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="8">
|
||||
<DataStatisticsChart
|
||||
ref="animalSpeciesChart"
|
||||
title="动物种类分布"
|
||||
chart-type="pie"
|
||||
data-source="/api/v1/admin/statistics/animal-species"
|
||||
@data-loaded="handleAnimalSpeciesLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="8">
|
||||
<DataStatisticsChart
|
||||
ref="revenueChart"
|
||||
title="收入统计"
|
||||
chart-type="bar"
|
||||
data-source="/api/v1/admin/statistics/revenue"
|
||||
:series-config="[
|
||||
{ name: '认领费用', dataKey: 'adoption_fee', color: '#1890ff' },
|
||||
{ name: '推广佣金', dataKey: 'commission', color: '#52c41a' }
|
||||
]"
|
||||
@data-loaded="handleRevenueLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 地理分布图 -->
|
||||
<a-row :gutter="16" class="chart-section">
|
||||
<a-col :span="24">
|
||||
<a-card title="用户地理分布" :loading="geoLoading">
|
||||
<template #extra>
|
||||
<a-radio-group v-model:value="geoViewType" @change="handleGeoViewChange">
|
||||
<a-radio-button value="users">用户分布</a-radio-button>
|
||||
<a-radio-button value="animals">动物分布</a-radio-button>
|
||||
<a-radio-button value="orders">订单分布</a-radio-button>
|
||||
</a-radio-group>
|
||||
</template>
|
||||
|
||||
<div ref="geoChartContainer" style="height: 400px;"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-row :gutter="16" class="table-section">
|
||||
<a-col :span="12">
|
||||
<a-card title="热门动物排行" size="small">
|
||||
<a-table
|
||||
:columns="animalRankingColumns"
|
||||
:data-source="animalRankingData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'rank'">
|
||||
<a-tag :color="index < 3 ? ['gold', 'silver', '#cd7f32'][index] : 'default'">
|
||||
{{ index + 1 }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'adoption_rate'">
|
||||
<a-progress
|
||||
:percent="record.adoption_rate"
|
||||
size="small"
|
||||
:show-info="false"
|
||||
/>
|
||||
{{ record.adoption_rate }}%
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<a-card title="活跃用户排行" size="small">
|
||||
<a-table
|
||||
:columns="userRankingColumns"
|
||||
:data-source="userRankingData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'rank'">
|
||||
<a-tag :color="index < 3 ? ['gold', 'silver', '#cd7f32'][index] : 'default'">
|
||||
{{ index + 1 }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar :src="record.avatar" size="small">
|
||||
{{ record.nickname?.charAt(0) }}
|
||||
</a-avatar>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
CheckCircleOutlined,
|
||||
HeartOutlined,
|
||||
DollarOutlined,
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import * as echarts from 'echarts'
|
||||
import DataStatisticsChart from '@/components/charts/DataStatisticsChart.vue'
|
||||
|
||||
// 概览数据
|
||||
const overviewData = ref({
|
||||
totalUsers: 12580,
|
||||
activeUsers: 8960,
|
||||
totalAnimals: 1250,
|
||||
totalRevenue: 156780.50,
|
||||
userGrowth: 12.5,
|
||||
activeGrowth: 8.3,
|
||||
animalGrowth: 15.2,
|
||||
revenueGrowth: 22.1
|
||||
})
|
||||
|
||||
// 日期范围
|
||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
||||
|
||||
// 加载状态
|
||||
const exportLoading = ref(false)
|
||||
const refreshLoading = ref(false)
|
||||
const geoLoading = ref(false)
|
||||
|
||||
// 地理分布图
|
||||
const geoViewType = ref('users')
|
||||
const geoChartContainer = ref<HTMLDivElement>()
|
||||
let geoChartInstance: echarts.ECharts | null = null
|
||||
|
||||
// 图表引用
|
||||
const userGrowthChart = ref()
|
||||
const businessChart = ref()
|
||||
const userTypeChart = ref()
|
||||
const animalSpeciesChart = ref()
|
||||
const revenueChart = ref()
|
||||
|
||||
// 动物排行数据
|
||||
const animalRankingColumns = [
|
||||
{ title: '排名', dataIndex: 'rank', key: 'rank', width: 80 },
|
||||
{ title: '动物名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '种类', dataIndex: 'species', key: 'species' },
|
||||
{ title: '认领次数', dataIndex: 'adoption_count', key: 'adoption_count' },
|
||||
{ title: '认领率', dataIndex: 'adoption_rate', key: 'adoption_rate' }
|
||||
]
|
||||
|
||||
const animalRankingData = ref([
|
||||
{ id: 1, name: '小白', species: '狗', adoption_count: 25, adoption_rate: 85 },
|
||||
{ id: 2, name: '咪咪', species: '猫', adoption_count: 22, adoption_rate: 78 },
|
||||
{ id: 3, name: '小黑', species: '狗', adoption_count: 20, adoption_rate: 72 },
|
||||
{ id: 4, name: '花花', species: '猫', adoption_count: 18, adoption_rate: 65 },
|
||||
{ id: 5, name: '豆豆', species: '兔子', adoption_count: 15, adoption_rate: 58 }
|
||||
])
|
||||
|
||||
// 用户排行数据
|
||||
const userRankingColumns = [
|
||||
{ title: '排名', dataIndex: 'rank', key: 'rank', width: 80 },
|
||||
{ title: '头像', dataIndex: 'avatar', key: 'avatar', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'nickname', key: 'nickname' },
|
||||
{ title: '认领数量', dataIndex: 'adoption_count', key: 'adoption_count' },
|
||||
{ title: '活跃度', dataIndex: 'activity_score', key: 'activity_score' }
|
||||
]
|
||||
|
||||
const userRankingData = ref([
|
||||
{ id: 1, nickname: '爱心天使', avatar: '', adoption_count: 8, activity_score: 95 },
|
||||
{ id: 2, nickname: '动物守护者', avatar: '', adoption_count: 6, activity_score: 88 },
|
||||
{ id: 3, nickname: '温暖之家', avatar: '', adoption_count: 5, activity_score: 82 },
|
||||
{ id: 4, nickname: '小动物之友', avatar: '', adoption_count: 4, activity_score: 76 },
|
||||
{ id: 5, nickname: '爱宠人士', avatar: '', adoption_count: 3, activity_score: 70 }
|
||||
])
|
||||
|
||||
/**
|
||||
* 处理日期范围变化
|
||||
*/
|
||||
const handleDateRangeChange = (dates: [Dayjs, Dayjs] | null) => {
|
||||
if (dates) {
|
||||
// 刷新所有图表数据
|
||||
handleRefreshAll()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出报表
|
||||
*/
|
||||
const handleExport = async () => {
|
||||
exportLoading.value = true
|
||||
|
||||
try {
|
||||
// 模拟导出过程
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 这里应该调用实际的导出API
|
||||
message.success('报表导出成功')
|
||||
} catch (error) {
|
||||
message.error('报表导出失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新所有数据
|
||||
*/
|
||||
const handleRefreshAll = async () => {
|
||||
refreshLoading.value = true
|
||||
|
||||
try {
|
||||
// 刷新概览数据
|
||||
await loadOverviewData()
|
||||
|
||||
// 刷新所有图表
|
||||
userGrowthChart.value?.refresh()
|
||||
businessChart.value?.refresh()
|
||||
userTypeChart.value?.refresh()
|
||||
animalSpeciesChart.value?.refresh()
|
||||
revenueChart.value?.refresh()
|
||||
|
||||
// 刷新地理分布图
|
||||
await loadGeoData()
|
||||
|
||||
message.success('数据刷新成功')
|
||||
} catch (error) {
|
||||
message.error('数据刷新失败')
|
||||
} finally {
|
||||
refreshLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载概览数据
|
||||
*/
|
||||
const loadOverviewData = async () => {
|
||||
// 这里应该调用实际的API接口
|
||||
// const response = await getOverviewStatistics()
|
||||
// overviewData.value = response.data
|
||||
|
||||
// 模拟数据更新
|
||||
overviewData.value = {
|
||||
...overviewData.value,
|
||||
totalUsers: overviewData.value.totalUsers + Math.floor(Math.random() * 100),
|
||||
activeUsers: overviewData.value.activeUsers + Math.floor(Math.random() * 50)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化地理分布图
|
||||
*/
|
||||
const initGeoChart = () => {
|
||||
if (!geoChartContainer.value) return
|
||||
|
||||
geoChartInstance = echarts.init(geoChartContainer.value)
|
||||
loadGeoData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载地理分布数据
|
||||
*/
|
||||
const loadGeoData = async () => {
|
||||
if (!geoChartInstance) return
|
||||
|
||||
geoLoading.value = true
|
||||
|
||||
try {
|
||||
// 模拟地理数据
|
||||
const geoData = [
|
||||
{ name: '北京', value: 1200 },
|
||||
{ name: '上海', value: 980 },
|
||||
{ name: '广东', value: 850 },
|
||||
{ name: '浙江', value: 720 },
|
||||
{ name: '江苏', value: 680 },
|
||||
{ name: '四川', value: 520 },
|
||||
{ name: '湖北', value: 450 },
|
||||
{ name: '河南', value: 380 }
|
||||
]
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: `${geoViewType.value === 'users' ? '用户' : geoViewType.value === 'animals' ? '动物' : '订单'}分布`,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c}'
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: 1200,
|
||||
left: 'left',
|
||||
top: 'bottom',
|
||||
text: ['高', '低'],
|
||||
calculable: true,
|
||||
inRange: {
|
||||
color: ['#e0f3ff', '#006edd']
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: geoViewType.value,
|
||||
type: 'map',
|
||||
map: 'china',
|
||||
roam: false,
|
||||
data: geoData
|
||||
}]
|
||||
}
|
||||
|
||||
geoChartInstance.setOption(option)
|
||||
} catch (error) {
|
||||
console.error('加载地理数据失败:', error)
|
||||
} finally {
|
||||
geoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理地理视图类型变化
|
||||
*/
|
||||
const handleGeoViewChange = () => {
|
||||
loadGeoData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 图表数据加载回调
|
||||
*/
|
||||
const handleUserGrowthLoaded = (data: any[]) => {
|
||||
console.log('用户增长数据加载完成:', data)
|
||||
}
|
||||
|
||||
const handleBusinessDataLoaded = (data: any[]) => {
|
||||
console.log('业务数据加载完成:', data)
|
||||
}
|
||||
|
||||
const handleUserTypeLoaded = (data: any[]) => {
|
||||
console.log('用户类型数据加载完成:', data)
|
||||
}
|
||||
|
||||
const handleAnimalSpeciesLoaded = (data: any[]) => {
|
||||
console.log('动物种类数据加载完成:', data)
|
||||
}
|
||||
|
||||
const handleRevenueLoaded = (data: any[]) => {
|
||||
console.log('收入数据加载完成:', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件挂载
|
||||
*/
|
||||
onMounted(() => {
|
||||
loadOverviewData()
|
||||
|
||||
nextTick(() => {
|
||||
initGeoChart()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.overview-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.statistic-trend {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trend {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.trend.up {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.trend.down {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.trend-text {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-section :deep(.ant-card),
|
||||
.table-section :deep(.ant-card) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-section :deep(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
172
admin-system/src/pages/users/components/UserDetail.vue
Normal file
172
admin-system/src/pages/users/components/UserDetail.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="用户详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div v-if="user" class="user-detail">
|
||||
<!-- 基本信息 -->
|
||||
<a-card title="基本信息" class="mb-4">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>用户ID:</label>
|
||||
<span>{{ user.id }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>用户名:</label>
|
||||
<span>{{ user.username }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>昵称:</label>
|
||||
<span>{{ user.nickname || '-' }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>邮箱:</label>
|
||||
<span>{{ user.email || '-' }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>手机号:</label>
|
||||
<span>{{ user.phone || '-' }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>状态:</label>
|
||||
<a-tag :color="getStatusColor(user.status)">
|
||||
{{ getStatusText(user.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<a-card title="统计信息" class="mb-4">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-statistic title="积分" :value="user.points" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic title="等级" :value="user.level" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic title="余额" :value="user.balance" prefix="¥" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic title="旅行次数" :value="user.travel_count" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<a-card title="时间信息">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>注册时间:</label>
|
||||
<span>{{ formatDate(user.created_at) }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>更新时间:</label>
|
||||
<span>{{ formatDate(user.updated_at) }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>最后登录:</label>
|
||||
<span>{{ user.last_login_at ? formatDate(user.last_login_at) : '-' }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { User } from '@/api/user'
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
active: 'green',
|
||||
inactive: 'red',
|
||||
pending: 'orange'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
active: '正常',
|
||||
inactive: '禁用',
|
||||
pending: '待审核'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-detail {
|
||||
.detail-item {
|
||||
margin-bottom: 12px;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
276
admin-system/src/pages/users/components/UserForm.vue
Normal file
276
admin-system/src/pages/users/components/UserForm.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="isEditing ? '编辑用户' : '新增用户'"
|
||||
width="600px"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input
|
||||
v-model:value="formData.username"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="isEditing"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input
|
||||
v-model:value="formData.nickname"
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input
|
||||
v-model:value="formData.email"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="手机号" name="phone">
|
||||
<a-input
|
||||
v-model:value="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" v-if="!isEditing">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="密码" name="password">
|
||||
<a-input-password
|
||||
v-model:value="formData.password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="确认密码" name="confirmPassword">
|
||||
<a-input-password
|
||||
v-model:value="formData.confirmPassword"
|
||||
placeholder="请确认密码"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="性别" name="gender">
|
||||
<a-select
|
||||
v-model:value="formData.gender"
|
||||
placeholder="请选择性别"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option :value="1">男</a-select-option>
|
||||
<a-select-option :value="2">女</a-select-option>
|
||||
<a-select-option :value="0">未知</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="生日" name="birthday">
|
||||
<a-date-picker
|
||||
v-model:value="formData.birthday"
|
||||
placeholder="请选择生日"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select
|
||||
v-model:value="formData.status"
|
||||
placeholder="请选择状态"
|
||||
>
|
||||
<a-select-option value="active">正常</a-select-option>
|
||||
<a-select-option value="inactive">禁用</a-select-option>
|
||||
<a-select-option value="pending">待审核</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-input
|
||||
v-model:value="formData.remark"
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
|
||||
import type { User } from '@/api/user'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
visible: boolean
|
||||
isEditing: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'submit', data: any): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
gender: undefined as number | undefined,
|
||||
birthday: undefined as Dayjs | undefined,
|
||||
status: 'active',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules: Record<string, Rule[]> = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: !props.isEditing, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: !props.isEditing, message: '请确认密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string) => {
|
||||
if (!props.isEditing && value !== formData.password) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 监听用户数据变化,初始化表单
|
||||
watch(() => props.user, (user) => {
|
||||
if (user && props.isEditing) {
|
||||
formData.username = user.username
|
||||
formData.nickname = user.nickname
|
||||
formData.email = user.email
|
||||
formData.phone = user.phone
|
||||
formData.gender = user.gender
|
||||
formData.birthday = user.birthday ? dayjs(user.birthday) : undefined
|
||||
formData.status = user.status
|
||||
formData.remark = user.remark
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听弹窗显示状态,重置表单
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible && !props.isEditing) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
username: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
gender: undefined,
|
||||
birthday: undefined,
|
||||
status: 'active',
|
||||
remark: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
const submitData: any = {
|
||||
username: formData.username,
|
||||
nickname: formData.nickname,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
gender: formData.gender,
|
||||
birthday: formData.birthday?.format('YYYY-MM-DD'),
|
||||
status: formData.status,
|
||||
remark: formData.remark
|
||||
}
|
||||
|
||||
if (!props.isEditing) {
|
||||
submitData.password = formData.password
|
||||
}
|
||||
|
||||
emit('submit', submitData)
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
843
admin-system/src/pages/users/index.vue
Normal file
843
admin-system/src/pages/users/index.vue
Normal file
@@ -0,0 +1,843 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<a-card title="用户管理" size="small">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined />
|
||||
新增用户
|
||||
</a-button>
|
||||
<a-button @click="handleRefresh">
|
||||
<ReloadOutlined />
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic
|
||||
title="总用户数"
|
||||
:value="statistics.totalUsers"
|
||||
:value-style="{ color: '#3f8600' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic
|
||||
title="活跃用户"
|
||||
:value="statistics.activeUsers"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic
|
||||
title="今日新增"
|
||||
:value="statistics.todayNew"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<PlusCircleOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic
|
||||
title="VIP用户"
|
||||
:value="statistics.vipUsers"
|
||||
:value-style="{ color: '#fa8c16' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<CrownOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 高级搜索 -->
|
||||
<AdvancedSearch
|
||||
search-type="user"
|
||||
:status-options="statusOptions"
|
||||
@search="handleSearch"
|
||||
@reset="handleSearchReset"
|
||||
/>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<BatchOperations
|
||||
:data-source="users"
|
||||
:selected-items="selectedUsers"
|
||||
operation-type="user"
|
||||
:status-options="statusOptions"
|
||||
:export-fields="exportFields"
|
||||
@selection-change="(items: any[]) => handleSelectionChange(items as User[])"
|
||||
@batch-action="(action: string, items: any[], params?: any) => handleBatchAction(action, items as User[], params)"
|
||||
/>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 1200 }"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<!-- 头像列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar :src="record.avatar" :size="40">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
</template>
|
||||
|
||||
<!-- 用户信息列 -->
|
||||
<template v-else-if="column.key === 'userInfo'">
|
||||
<div class="user-info">
|
||||
<div class="user-name">
|
||||
{{ record.nickname || record.username }}
|
||||
<a-tag v-if="record.user_type === 'vip'" color="gold" size="small">
|
||||
<CrownOutlined />
|
||||
VIP
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
ID: {{ record.id }} | {{ record.phone || record.email }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 注册信息列 -->
|
||||
<template v-else-if="column.key === 'registerInfo'">
|
||||
<div class="register-info">
|
||||
<div>
|
||||
<a-tag :color="getSourceColor(record.register_source)" size="small">
|
||||
{{ getSourceText(record.register_source) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="register-time">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 最后登录列 -->
|
||||
<template v-else-if="column.key === 'lastLogin'">
|
||||
<div v-if="record.last_login_at">
|
||||
<div>{{ formatDate(record.last_login_at) }}</div>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
{{ record.last_login_ip }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-typography-text v-else type="secondary">
|
||||
从未登录
|
||||
</a-typography-text>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleMenuAction(key, record)">
|
||||
<a-menu-item key="resetPassword">
|
||||
<KeyOutlined />
|
||||
重置密码
|
||||
</a-menu-item>
|
||||
<a-menu-item key="sendMessage">
|
||||
<MessageOutlined />
|
||||
发送消息
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
:key="record.status === 'active' ? 'disable' : 'enable'"
|
||||
>
|
||||
<component
|
||||
:is="record.status === 'active' ? LockOutlined : UnlockOutlined"
|
||||
/>
|
||||
{{ record.status === 'active' ? '禁用' : '启用' }}
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="link" size="small">
|
||||
更多
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 用户详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="detailModalVisible"
|
||||
title="用户详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<UserDetail
|
||||
v-if="currentUser"
|
||||
:user="currentUser"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 用户编辑模态框 -->
|
||||
<a-modal
|
||||
v-model:open="editModalVisible"
|
||||
title="编辑用户"
|
||||
@ok="handleEditSubmit"
|
||||
:confirm-loading="editLoading"
|
||||
>
|
||||
<UserForm
|
||||
v-if="currentUser"
|
||||
ref="userFormRef"
|
||||
:user="currentUser"
|
||||
mode="edit"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 新增用户模态框 -->
|
||||
<a-modal
|
||||
v-model:open="addModalVisible"
|
||||
title="新增用户"
|
||||
@ok="handleAddSubmit"
|
||||
:confirm-loading="addLoading"
|
||||
>
|
||||
<UserForm
|
||||
ref="addUserFormRef"
|
||||
mode="add"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 发送消息模态框 -->
|
||||
<a-modal
|
||||
v-model:open="messageModalVisible"
|
||||
title="发送消息"
|
||||
@ok="handleSendMessage"
|
||||
:confirm-loading="messageLoading"
|
||||
>
|
||||
<a-form :model="messageForm" layout="vertical">
|
||||
<a-form-item label="消息标题" required>
|
||||
<a-input
|
||||
v-model:value="messageForm.title"
|
||||
placeholder="请输入消息标题"
|
||||
:maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="消息内容" required>
|
||||
<a-textarea
|
||||
v-model:value="messageForm.content"
|
||||
placeholder="请输入消息内容"
|
||||
:rows="4"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="消息类型">
|
||||
<a-radio-group v-model:value="messageForm.type">
|
||||
<a-radio value="info">通知</a-radio>
|
||||
<a-radio value="warning">警告</a-radio>
|
||||
<a-radio value="promotion">推广</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
PlusCircleOutlined,
|
||||
CrownOutlined,
|
||||
KeyOutlined,
|
||||
MessageOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined,
|
||||
DeleteOutlined,
|
||||
DownOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { TableColumnsType, TableProps } from 'ant-design-vue'
|
||||
import AdvancedSearch from '@/components/AdvancedSearch.vue'
|
||||
import BatchOperations from '@/components/BatchOperations.vue'
|
||||
import UserDetail from './components/UserDetail.vue'
|
||||
import UserForm from './components/UserForm.vue'
|
||||
import userAPI, { type User } from '@/api/user'
|
||||
// import { formatDate } from '@/utils/date'
|
||||
|
||||
interface Statistics {
|
||||
totalUsers: number
|
||||
activeUsers: number
|
||||
newUsersToday: number
|
||||
totalRevenue: number
|
||||
}
|
||||
|
||||
// 临时格式化函数,直到utils/date模块可用
|
||||
const formatDate = (date: string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const users = ref<User[]>([])
|
||||
const selectedUsers = ref<User[]>([])
|
||||
const currentUser = ref<User | null>(null)
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref<Statistics>({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
todayNew: 0,
|
||||
vipUsers: 0
|
||||
})
|
||||
|
||||
// 模态框状态
|
||||
const detailModalVisible = ref(false)
|
||||
const editModalVisible = ref(false)
|
||||
const addModalVisible = ref(false)
|
||||
const messageModalVisible = ref(false)
|
||||
|
||||
// 加载状态
|
||||
const editLoading = ref(false)
|
||||
const addLoading = ref(false)
|
||||
const messageLoading = ref(false)
|
||||
|
||||
// 表单引用
|
||||
const userFormRef = ref()
|
||||
const addUserFormRef = ref()
|
||||
|
||||
// 消息表单
|
||||
const messageForm = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条记录`
|
||||
})
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = ref({})
|
||||
|
||||
// 状态选项
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: '激活', color: 'green' },
|
||||
{ value: 'inactive', label: '禁用', color: 'red' },
|
||||
{ value: 'pending', label: '待审核', color: 'orange' }
|
||||
]
|
||||
|
||||
// 导出字段
|
||||
const exportFields = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'nickname', label: '昵称' },
|
||||
{ key: 'email', label: '邮箱' },
|
||||
{ key: 'phone', label: '手机号' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'user_type', label: '用户类型' },
|
||||
{ key: 'register_source', label: '注册来源' },
|
||||
{ key: 'created_at', label: '注册时间' },
|
||||
{ key: 'last_login_at', label: '最后登录' }
|
||||
]
|
||||
|
||||
// 表格列配置
|
||||
const columns: TableColumnsType = [
|
||||
{
|
||||
title: '头像',
|
||||
key: 'avatar',
|
||||
width: 80,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '用户信息',
|
||||
key: 'userInfo',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '注册信息',
|
||||
key: 'registerInfo',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
key: 'lastLogin',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection: TableProps['rowSelection'] = {
|
||||
selectedRowKeys: computed(() => selectedUsers.value.map(user => user.id)),
|
||||
onChange: (selectedRowKeys: (string | number)[], selectedRows: User[]) => {
|
||||
selectedUsers.value = selectedRows
|
||||
},
|
||||
onSelectAll: (selected: boolean, selectedRows: User[], changeRows: User[]) => {
|
||||
if (selected) {
|
||||
selectedUsers.value = [...selectedUsers.value, ...changeRows]
|
||||
} else {
|
||||
const changeIds = changeRows.map(row => row.id)
|
||||
selectedUsers.value = selectedUsers.value.filter(user => !changeIds.includes(user.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态颜色
|
||||
*/
|
||||
const getStatusColor = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
active: 'green',
|
||||
inactive: 'red',
|
||||
pending: 'orange'
|
||||
}
|
||||
return statusMap[status] || 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态文本
|
||||
*/
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
active: '激活',
|
||||
inactive: '禁用',
|
||||
pending: '待审核'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取来源颜色
|
||||
*/
|
||||
const getSourceColor = (source: string) => {
|
||||
const sourceMap: Record<string, string> = {
|
||||
web: 'blue',
|
||||
wechat: 'green',
|
||||
app: 'purple'
|
||||
}
|
||||
return sourceMap[source] || 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取来源文本
|
||||
*/
|
||||
const getSourceText = (source: string) => {
|
||||
const sourceMap: Record<string, string> = {
|
||||
web: '网页端',
|
||||
wechat: '微信小程序',
|
||||
app: '移动应用'
|
||||
}
|
||||
return sourceMap[source] || source
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载用户列表
|
||||
*/
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.current,
|
||||
pageSize: pagination.value.pageSize,
|
||||
...searchParams.value
|
||||
}
|
||||
|
||||
const response = await userAPI.getUsers(params)
|
||||
|
||||
users.value = response.data.list
|
||||
pagination.value.total = response.data.total
|
||||
|
||||
// 更新统计数据
|
||||
statistics.value = response.data.statistics || {
|
||||
totalUsers: response.data.total,
|
||||
activeUsers: response.data.list.filter((u: User) => u.status === 'active').length,
|
||||
todayNew: 0,
|
||||
vipUsers: response.data.list.filter((u: User) => u.user_type === 'vip').length
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表格变化
|
||||
*/
|
||||
const handleTableChange: TableProps['onChange'] = (pag) => {
|
||||
if (pag) {
|
||||
pagination.value.current = pag.current || 1
|
||||
pagination.value.pageSize = pag.pageSize || 20
|
||||
}
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = (params: any) => {
|
||||
searchParams.value = params
|
||||
pagination.value.current = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索重置
|
||||
*/
|
||||
const handleSearchReset = () => {
|
||||
searchParams.value = {}
|
||||
pagination.value.current = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理选择变化
|
||||
*/
|
||||
const handleSelectionChange = (items: User[]) => {
|
||||
selectedUsers.value = items
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量操作
|
||||
*/
|
||||
const handleBatchAction = async (action: string, items: User[], params?: any) => {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'update-status':
|
||||
await userAPI.batchUpdateStatus(
|
||||
items.map(item => item.id),
|
||||
params.status,
|
||||
params.reason
|
||||
)
|
||||
message.success('批量状态更新成功')
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
await userAPI.batchDelete(items.map(item => item.id))
|
||||
message.success('批量删除成功')
|
||||
break
|
||||
|
||||
case 'export':
|
||||
await userAPI.exportUsers(items.map(item => item.id), params)
|
||||
message.success('导出任务已开始')
|
||||
break
|
||||
|
||||
case 'send-message':
|
||||
// 打开批量发送消息界面
|
||||
messageModalVisible.value = true
|
||||
break
|
||||
|
||||
case 'lock':
|
||||
await userAPI.batchUpdateStatus(items.map(item => item.id), 'inactive', '批量锁定')
|
||||
message.success('批量锁定成功')
|
||||
break
|
||||
|
||||
case 'unlock':
|
||||
await userAPI.batchUpdateStatus(items.map(item => item.id), 'active', '批量解锁')
|
||||
message.success('批量解锁成功')
|
||||
break
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
loadUsers()
|
||||
// 清空选择
|
||||
selectedUsers.value = []
|
||||
} catch (error) {
|
||||
message.error('批量操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理查看
|
||||
*/
|
||||
const handleView = (user: User) => {
|
||||
currentUser.value = user
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理编辑
|
||||
*/
|
||||
const handleEdit = (user: User) => {
|
||||
currentUser.value = user
|
||||
editModalVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新增
|
||||
*/
|
||||
const handleAdd = () => {
|
||||
addModalVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理刷新
|
||||
*/
|
||||
const handleRefresh = () => {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单操作
|
||||
*/
|
||||
const handleMenuAction = async (key: string, user: User) => {
|
||||
switch (key) {
|
||||
case 'resetPassword':
|
||||
Modal.confirm({
|
||||
title: '确认重置密码',
|
||||
content: `确定要重置用户 ${user.nickname || user.username} 的密码吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userAPI.resetPassword(user.id)
|
||||
message.success('密码重置成功')
|
||||
} catch (error) {
|
||||
message.error('密码重置失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'sendMessage':
|
||||
currentUser.value = user
|
||||
messageModalVisible.value = true
|
||||
break
|
||||
|
||||
case 'enable':
|
||||
case 'disable':
|
||||
const newStatus = key === 'enable' ? 'active' : 'inactive'
|
||||
const action = key === 'enable' ? '启用' : '禁用'
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认${action}用户`,
|
||||
content: `确定要${action}用户 ${user.nickname || user.username} 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userAPI.updateStatus(user.id, newStatus)
|
||||
message.success(`${action}成功`)
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
message.error(`${action}失败`)
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
Modal.confirm({
|
||||
title: '确认删除用户',
|
||||
content: `确定要删除用户 ${user.nickname || user.username} 吗?此操作不可撤销!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userAPI.deleteUser(user.id)
|
||||
message.success('删除成功')
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理编辑提交
|
||||
*/
|
||||
const handleEditSubmit = async () => {
|
||||
if (!userFormRef.value) return
|
||||
|
||||
editLoading.value = true
|
||||
|
||||
try {
|
||||
const formData = await userFormRef.value.validate()
|
||||
await userAPI.updateUser(currentUser.value!.id, formData)
|
||||
|
||||
message.success('更新成功')
|
||||
editModalVisible.value = false
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
message.error('更新失败')
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新增提交
|
||||
*/
|
||||
const handleAddSubmit = async () => {
|
||||
if (!addUserFormRef.value) return
|
||||
|
||||
addLoading.value = true
|
||||
|
||||
try {
|
||||
const formData = await addUserFormRef.value.validate()
|
||||
await userAPI.createUser(formData)
|
||||
|
||||
message.success('创建成功')
|
||||
addModalVisible.value = false
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
message.error('创建失败')
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理发送消息
|
||||
*/
|
||||
const handleSendMessage = async () => {
|
||||
if (!messageForm.title || !messageForm.content) {
|
||||
message.error('请填写完整的消息信息')
|
||||
return
|
||||
}
|
||||
|
||||
messageLoading.value = true
|
||||
|
||||
try {
|
||||
const userIds = currentUser.value
|
||||
? [currentUser.value.id]
|
||||
: selectedUsers.value.map(user => user.id)
|
||||
|
||||
await userAPI.sendMessage(userIds, messageForm)
|
||||
|
||||
message.success('消息发送成功')
|
||||
messageModalVisible.value = false
|
||||
|
||||
// 重置表单
|
||||
messageForm.title = ''
|
||||
messageForm.content = ''
|
||||
messageForm.type = 'info'
|
||||
} catch (error) {
|
||||
message.error('消息发送失败')
|
||||
} finally {
|
||||
messageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.register-info {
|
||||
.register-time {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.danger-item) {
|
||||
color: #ff4d4f !important;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.users-page {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.stats-cards :deep(.ant-col) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
166
admin-system/src/utils/date.ts
Normal file
166
admin-system/src/utils/date.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
// 配置dayjs插件
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param date 日期
|
||||
* @param format 格式化字符串,默认为 'YYYY-MM-DD HH:mm:ss'
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export const formatDate = (date: string | Date | dayjs.Dayjs | null | undefined, format = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
if (!date) return ''
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
* @param date 日期
|
||||
* @returns 相对时间字符串,如 "2小时前"
|
||||
*/
|
||||
export const formatRelativeTime = (date: string | Date | dayjs.Dayjs | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
return dayjs(date).fromNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为友好显示
|
||||
* @param date 日期
|
||||
* @returns 友好的日期显示
|
||||
*/
|
||||
export const formatFriendlyDate = (date: string | Date | dayjs.Dayjs | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
const now = dayjs()
|
||||
const target = dayjs(date)
|
||||
const diffDays = now.diff(target, 'day')
|
||||
|
||||
if (diffDays === 0) {
|
||||
return target.format('HH:mm')
|
||||
} else if (diffDays === 1) {
|
||||
return `昨天 ${target.format('HH:mm')}`
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}天前`
|
||||
} else {
|
||||
return target.format('MM-DD HH:mm')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为日期(不包含时间)
|
||||
* @param date 日期字符串或Date对象
|
||||
* @returns 格式化后的日期字符串,如"2024-01-15"
|
||||
*/
|
||||
export const formatDateOnly = (date: string | Date | null | undefined): string => {
|
||||
return formatDate(date, 'YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为时间(不包含日期)
|
||||
* @param date 日期字符串或Date对象
|
||||
* @returns 格式化后的时间字符串,如"14:30:25"
|
||||
*/
|
||||
export const formatTimeOnly = (date: string | Date | null | undefined): string => {
|
||||
return formatDate(date, 'HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为中文日期时间
|
||||
* @param date 日期字符串或Date对象
|
||||
* @returns 中文格式的日期时间字符串,如"2024年1月15日 14:30"
|
||||
*/
|
||||
export const formatChineseDateTime = (date: string | Date | null | undefined): string => {
|
||||
return formatDate(date, 'YYYY年M月D日 HH:mm')
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为今天
|
||||
* @param date 日期
|
||||
* @returns 是否为今天
|
||||
*/
|
||||
export const isToday = (date: string | Date | dayjs.Dayjs | null | undefined): boolean => {
|
||||
if (!date) return false
|
||||
return dayjs(date).isSame(dayjs(), 'day')
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为本周
|
||||
* @param date 日期
|
||||
* @returns 是否为本周
|
||||
*/
|
||||
export const isThisWeek = (date: string | Date | dayjs.Dayjs | null | undefined): boolean => {
|
||||
if (!date) return false
|
||||
return dayjs(date).isSame(dayjs(), 'week')
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为本月
|
||||
* @param date 日期
|
||||
* @returns 是否为本月
|
||||
*/
|
||||
export const isThisMonth = (date: string | Date | dayjs.Dayjs | null | undefined): boolean => {
|
||||
if (!date) return false
|
||||
return dayjs(date).isSame(dayjs(), 'month')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时间范围
|
||||
* @param type 时间范围类型
|
||||
* @returns 时间范围数组 [开始时间, 结束时间]
|
||||
*/
|
||||
export const getTimeRange = (type: 'today' | 'yesterday' | 'week' | 'month' | 'year'): [dayjs.Dayjs, dayjs.Dayjs] => {
|
||||
const now = dayjs()
|
||||
|
||||
switch (type) {
|
||||
case 'today':
|
||||
return [now.startOf('day'), now.endOf('day')]
|
||||
case 'yesterday':
|
||||
const yesterday = now.subtract(1, 'day')
|
||||
return [yesterday.startOf('day'), yesterday.endOf('day')]
|
||||
case 'week':
|
||||
return [now.startOf('week'), now.endOf('week')]
|
||||
case 'month':
|
||||
return [now.startOf('month'), now.endOf('month')]
|
||||
case 'year':
|
||||
return [now.startOf('year'), now.endOf('year')]
|
||||
default:
|
||||
return [now.startOf('day'), now.endOf('day')]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换时区
|
||||
* @param date 日期
|
||||
* @param timezone 目标时区
|
||||
* @returns 转换后的日期
|
||||
*/
|
||||
export const convertTimezone = (date: string | Date | dayjs.Dayjs, timezone: string): dayjs.Dayjs => {
|
||||
return dayjs(date).tz(timezone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时区
|
||||
* @returns 当前时区
|
||||
*/
|
||||
export const getCurrentTimezone = (): string => {
|
||||
return dayjs.tz.guess()
|
||||
}
|
||||
|
||||
export default {
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
formatFriendlyDate,
|
||||
isToday,
|
||||
isThisWeek,
|
||||
isThisMonth,
|
||||
getTimeRange,
|
||||
convertTimezone,
|
||||
getCurrentTimezone
|
||||
}
|
||||
Reference in New Issue
Block a user