重构认证系统和订单支付功能,新增邮箱验证、密码重置及支付流程

This commit is contained in:
2025-09-20 16:15:59 +08:00
parent 68a96b7e82
commit 467a4ead10
60 changed files with 32222 additions and 63 deletions

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

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

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

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

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

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

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

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

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

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

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