first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { httpDelete, httpGet, httpPost, httpPut } from '@/utils/request'
|
||||
import type { PageResult } from '@/types/common'
|
||||
import type {
|
||||
AdminCreateRequest,
|
||||
AdminInfo,
|
||||
AdminUpdateRequest,
|
||||
} from '@/types/auth'
|
||||
|
||||
export interface AdminListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
/** 管理员分页列表(超管专属) */
|
||||
export function getAdminList(query: AdminListQuery): Promise<PageResult<AdminInfo>> {
|
||||
return httpGet<PageResult<AdminInfo>>('/admins', { ...query })
|
||||
}
|
||||
|
||||
/** 创建管理员 */
|
||||
export function createAdmin(data: AdminCreateRequest): Promise<AdminInfo> {
|
||||
return httpPost<AdminInfo>('/admins', data)
|
||||
}
|
||||
|
||||
/** 更新管理员 */
|
||||
export function updateAdmin(id: number, data: AdminUpdateRequest): Promise<Record<string, never>> {
|
||||
return httpPut<Record<string, never>>(`/admins/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除管理员(软删) */
|
||||
export function deleteAdmin(id: number): Promise<Record<string, never>> {
|
||||
return httpDelete<Record<string, never>>(`/admins/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { httpPost, httpPut } from '@/utils/request'
|
||||
import type {
|
||||
AdminChangePasswordRequest,
|
||||
AdminLoginRequest,
|
||||
AdminLoginResponse,
|
||||
AdminRefreshTokenRequest,
|
||||
AdminRefreshTokenResponse,
|
||||
} from '@/types/auth'
|
||||
|
||||
/** 管理员登录 */
|
||||
export function login(data: AdminLoginRequest): Promise<AdminLoginResponse> {
|
||||
return httpPost<AdminLoginResponse>('/admin/login', data)
|
||||
}
|
||||
|
||||
/** 刷新双 token */
|
||||
export function refreshToken(data: AdminRefreshTokenRequest): Promise<AdminRefreshTokenResponse> {
|
||||
return httpPost<AdminRefreshTokenResponse>('/admin/refresh-token', data)
|
||||
}
|
||||
|
||||
/** 登出(吊销 refresh + 当前 access) */
|
||||
export function logout(refresh_token: string): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>('/admin/logout', { refresh_token })
|
||||
}
|
||||
|
||||
/** 修改自己密码 */
|
||||
export function changePassword(data: AdminChangePasswordRequest): Promise<Record<string, never>> {
|
||||
return httpPut<Record<string, never>>('/admin/password', data)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import type { PageResult } from '@/types/common'
|
||||
import type {
|
||||
ClosetShareItem,
|
||||
ClosetShareListQuery,
|
||||
ClothesItem,
|
||||
ClothesListQuery,
|
||||
OutfitAuditRequest,
|
||||
OutfitBanRequest,
|
||||
OutfitDetailResponse,
|
||||
OutfitListItem,
|
||||
OutfitListQuery,
|
||||
} from '@/types/content'
|
||||
|
||||
/* ---------------- 穿搭 ---------------- */
|
||||
export function getOutfitList(query: OutfitListQuery): Promise<PageResult<OutfitListItem>> {
|
||||
return httpGet<PageResult<OutfitListItem>>('/outfits', { ...query })
|
||||
}
|
||||
|
||||
export function getOutfitDetail(uuid: string): Promise<OutfitDetailResponse> {
|
||||
return httpGet<OutfitDetailResponse>(`/outfits/${uuid}`)
|
||||
}
|
||||
|
||||
export function auditOutfit(uuid: string, data: OutfitAuditRequest): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>(`/outfits/${uuid}/audit`, data)
|
||||
}
|
||||
|
||||
export function banOutfit(uuid: string, data: OutfitBanRequest): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>(`/outfits/${uuid}/ban`, data)
|
||||
}
|
||||
|
||||
export function unbanOutfit(uuid: string): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>(`/outfits/${uuid}/unban`)
|
||||
}
|
||||
|
||||
/* ---------------- 衣物 ---------------- */
|
||||
export function getClothesList(query: ClothesListQuery): Promise<PageResult<ClothesItem>> {
|
||||
return httpGet<PageResult<ClothesItem>>('/clothes', { ...query })
|
||||
}
|
||||
|
||||
export function getClothesDetail(uuid: string): Promise<ClothesItem> {
|
||||
return httpGet<ClothesItem>(`/clothes/${uuid}`)
|
||||
}
|
||||
|
||||
/* ---------------- 衣柜分享 ---------------- */
|
||||
export function getClosetShareList(query: ClosetShareListQuery): Promise<PageResult<ClosetShareItem>> {
|
||||
return httpGet<PageResult<ClosetShareItem>>('/closet-shares', { ...query })
|
||||
}
|
||||
|
||||
export function banClosetShare(uuid: string): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>(`/closet-shares/${uuid}/ban`)
|
||||
}
|
||||
|
||||
export function unbanClosetShare(uuid: string): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>(`/closet-shares/${uuid}/unban`)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { httpGet } from '@/utils/request'
|
||||
import type { DashboardSummary, DashboardTrends } from '@/types/dashboard'
|
||||
|
||||
/** 仪表盘核心指标聚合 */
|
||||
export function getDashboardSummary(): Promise<DashboardSummary> {
|
||||
return httpGet<DashboardSummary>('/dashboard/summary')
|
||||
}
|
||||
|
||||
/** 仪表盘近 N 天趋势 */
|
||||
export function getDashboardTrends(days = 30): Promise<DashboardTrends> {
|
||||
return httpGet<DashboardTrends>('/dashboard/trends', { days })
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { httpDelete, httpGet, httpPost, httpPut } from '@/utils/request'
|
||||
import type { PageResult } from '@/types/common'
|
||||
import type {
|
||||
GoodsCreateRequest,
|
||||
GoodsItem,
|
||||
GoodsListQuery,
|
||||
GoodsStatusRequest,
|
||||
GoodsUpdateRequest,
|
||||
} from '@/types/goods'
|
||||
|
||||
/** 商品分页列表(含下架) */
|
||||
export function getGoodsList(query: GoodsListQuery): Promise<PageResult<GoodsItem>> {
|
||||
return httpGet<PageResult<GoodsItem>>('/goods', { ...query })
|
||||
}
|
||||
|
||||
/** 创建商品 */
|
||||
export function createGoods(data: GoodsCreateRequest): Promise<GoodsItem> {
|
||||
return httpPost<GoodsItem>('/goods', data)
|
||||
}
|
||||
|
||||
/** 更新商品(传什么改什么) */
|
||||
export function updateGoods(id: number, data: GoodsUpdateRequest): Promise<Record<string, never>> {
|
||||
return httpPut<Record<string, never>>(`/goods/${id}`, data)
|
||||
}
|
||||
|
||||
/** 上下架 */
|
||||
export function updateGoodsStatus(id: number, data: GoodsStatusRequest): Promise<Record<string, never>> {
|
||||
return httpPut<Record<string, never>>(`/goods/${id}/status`, data)
|
||||
}
|
||||
|
||||
/** 软删除商品 */
|
||||
export function deleteGoods(id: number): Promise<Record<string, never>> {
|
||||
return httpDelete<Record<string, never>>(`/goods/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { httpPost } from '@/utils/request'
|
||||
import type {
|
||||
NotificationBroadcastRequest,
|
||||
NotificationBroadcastResponse,
|
||||
NotificationSendRequest,
|
||||
} from '@/types/notification'
|
||||
|
||||
/** 单用户系统通知 */
|
||||
export function sendNotification(data: NotificationSendRequest): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>('/notifications', data)
|
||||
}
|
||||
|
||||
/** 全量广播 */
|
||||
export function broadcastNotification(data: NotificationBroadcastRequest): Promise<NotificationBroadcastResponse> {
|
||||
return httpPost<NotificationBroadcastResponse>('/notifications/broadcast', data)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import type { PageResult } from '@/types/common'
|
||||
import type {
|
||||
OrderItem,
|
||||
OrderListQuery,
|
||||
RefundAuditRequest,
|
||||
RefundItem,
|
||||
RefundListQuery,
|
||||
} from '@/types/order'
|
||||
|
||||
/** 订单分页列表 */
|
||||
export function getOrderList(query: OrderListQuery): Promise<PageResult<OrderItem>> {
|
||||
return httpGet<PageResult<OrderItem>>('/orders', { ...query })
|
||||
}
|
||||
|
||||
/** 订单详情 */
|
||||
export function getOrderDetail(orderNo: string): Promise<OrderItem> {
|
||||
return httpGet<OrderItem>(`/orders/${orderNo}`)
|
||||
}
|
||||
|
||||
/** 退款单分页列表 */
|
||||
export function getRefundList(query: RefundListQuery): Promise<PageResult<RefundItem>> {
|
||||
return httpGet<PageResult<RefundItem>>('/refunds', { ...query })
|
||||
}
|
||||
|
||||
/** 退款审核通过 */
|
||||
export function approveRefund(refundNo: string, data: RefundAuditRequest): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>(`/refunds/${refundNo}/approve`, data)
|
||||
}
|
||||
|
||||
/** 退款审核驳回 */
|
||||
export function rejectRefund(refundNo: string, data: RefundAuditRequest): Promise<Record<string, never>> {
|
||||
return httpPost<Record<string, never>>(`/refunds/${refundNo}/reject`, data)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { httpDelete, httpGet, httpPut } from '@/utils/request'
|
||||
import type { PageResult } from '@/types/common'
|
||||
import type {
|
||||
UserBanRequest,
|
||||
UserDetailResponse,
|
||||
UserListItem,
|
||||
UserListQuery,
|
||||
UserQuotaAdjustRequest,
|
||||
UserUnbanRequest,
|
||||
UserVIPAdjustRequest,
|
||||
} from '@/types/user'
|
||||
|
||||
/** 用户分页列表 */
|
||||
export function getUserList(query: UserListQuery): Promise<PageResult<UserListItem>> {
|
||||
return httpGet<PageResult<UserListItem>>('/users', { ...query })
|
||||
}
|
||||
|
||||
/** 用户详情(含四类额度) */
|
||||
export function getUserDetail(uuid: string): Promise<UserDetailResponse> {
|
||||
return httpGet<UserDetailResponse>(`/users/${uuid}`)
|
||||
}
|
||||
|
||||
/** 封禁用户 */
|
||||
export function banUser(uuid: string, data: UserBanRequest): Promise<Record<string, never>> {
|
||||
return httpPut<Record<string, never>>(`/users/${uuid}/ban`, data)
|
||||
}
|
||||
|
||||
/** 解封用户 */
|
||||
export function unbanUser(uuid: string, data: UserUnbanRequest): Promise<Record<string, never>> {
|
||||
return httpPut<Record<string, never>>(`/users/${uuid}/unban`, data)
|
||||
}
|
||||
|
||||
/** 额度人工调整 */
|
||||
export function adjustUserQuota(uuid: string, data: UserQuotaAdjustRequest): Promise<Record<string, never>> {
|
||||
return httpPut<Record<string, never>>(`/users/${uuid}/quota`, data)
|
||||
}
|
||||
|
||||
/** VIP 人工调整 */
|
||||
export function adjustUserVip(uuid: string, data: UserVIPAdjustRequest): Promise<Record<string, never>> {
|
||||
return httpPut<Record<string, never>>(`/users/${uuid}/vip`, data)
|
||||
}
|
||||
|
||||
/** 回收额度凭证 */
|
||||
export function revokeGrant(uuid: string, grantId: number): Promise<Record<string, never>> {
|
||||
return httpDelete<Record<string, never>>(`/users/${uuid}/grants/${grantId}`)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Element Plus 主题定制:靛蓝主色 + 深色侧边栏协调色 */
|
||||
:root {
|
||||
--el-color-primary: #4f6ef7;
|
||||
--el-color-primary-light-3: #7d93f9;
|
||||
--el-color-primary-light-5: #a7b6fb;
|
||||
--el-color-primary-light-7: #d1d9fd;
|
||||
--el-color-primary-light-8: #e4e9fe;
|
||||
--el-color-primary-light-9: #f2f4fe;
|
||||
--el-color-primary-dark-2: #3f58c6;
|
||||
|
||||
--el-border-radius-base: 8px;
|
||||
--el-font-size-base: 14px;
|
||||
|
||||
/* 管理后台侧边栏配色 */
|
||||
--zt-sidebar-bg: #1e2438;
|
||||
--zt-sidebar-bg-light: #272f4a;
|
||||
--zt-sidebar-text: #9aa3c0;
|
||||
--zt-sidebar-active: #4f6ef7;
|
||||
}
|
||||
|
||||
/* 全局滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c6cbdd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8aec7;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'PingFang SC',
|
||||
'Helvetica Neue',
|
||||
'Microsoft YaHei',
|
||||
'Segoe UI',
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
color: #2b3044;
|
||||
background: #f4f6fb;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 通用页面卡片 */
|
||||
.zt-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.zt-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2437;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 金额与数字 */
|
||||
.zt-money {
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 表格操作列 */
|
||||
.zt-table-actions .el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 分页右对齐 */
|
||||
.zt-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 筛选区 */
|
||||
.zt-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.zt-filter .el-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.zt-filter .el-select {
|
||||
width: 160px;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
option: EChartsOption
|
||||
height?: string
|
||||
}>(),
|
||||
{
|
||||
height: '300px',
|
||||
},
|
||||
)
|
||||
|
||||
const chartRef = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function render() {
|
||||
if (!chart) return
|
||||
chart.setOption(props.option, true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!chartRef.value) return
|
||||
chart = echarts.init(chartRef.value)
|
||||
render()
|
||||
resizeObserver = new ResizeObserver(() => chart?.resize())
|
||||
resizeObserver.observe(chartRef.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.option,
|
||||
() => render(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="chartRef" class="base-chart" :style="{ height }"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.base-chart {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}>(),
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [value: number]
|
||||
'update:pageSize': [value: number]
|
||||
change: []
|
||||
}>()
|
||||
|
||||
const currentPage = computed({
|
||||
get: () => props.page,
|
||||
set: (val: number) => emit('update:page', val),
|
||||
})
|
||||
|
||||
const currentSize = computed({
|
||||
get: () => props.pageSize,
|
||||
set: (val: number) => emit('update:pageSize', val),
|
||||
})
|
||||
|
||||
function handleChange() {
|
||||
emit('change')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pagination-bar">
|
||||
<span class="total-text">共 {{ total }} 条</span>
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="currentSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
@current-change="handleChange"
|
||||
@size-change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.total-text {
|
||||
font-size: 13px;
|
||||
color: #9aa3c0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: []
|
||||
reset: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-card">
|
||||
<div class="search-fields">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="search-actions">
|
||||
<el-button type="primary" :icon="Search" @click="emit('search')">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="emit('reset')">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-fields :deep(.el-input),
|
||||
.search-fields :deep(.el-select) {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import SidebarMenu from './SidebarMenu.vue'
|
||||
import HeaderBar from './HeaderBar.vue'
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<aside class="layout-aside" :class="{ collapsed }">
|
||||
<div class="aside-brand" :class="{ collapsed }">
|
||||
<div class="brand-icon">
|
||||
<el-icon :size="22"><ShoppingBag /></el-icon>
|
||||
</div>
|
||||
<transition name="fade-text">
|
||||
<span v-show="!collapsed" class="brand-title">纸条衣橱管理后台</span>
|
||||
</transition>
|
||||
</div>
|
||||
<SidebarMenu :collapsed="collapsed" />
|
||||
</aside>
|
||||
|
||||
<div class="layout-main">
|
||||
<HeaderBar :collapsed="collapsed" @toggle="toggleCollapse" />
|
||||
<main class="layout-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page-fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--zt-sidebar-bg);
|
||||
transition: width 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-aside.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.aside-brand {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.aside-brand.collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4f6ef7, #8b5cf6);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fade-text-enter-active,
|
||||
.fade-text-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.fade-text-enter-from,
|
||||
.fade-text-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.page-fade-enter-active,
|
||||
.page-fade-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
.page-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.page-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Expand, Fold } from '@element-plus/icons-vue'
|
||||
import { changePassword, logout } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineProps<{ collapsed: boolean }>()
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const displayName = computed(
|
||||
() => authStore.adminInfo?.nickname || authStore.adminInfo?.username || '管理员',
|
||||
)
|
||||
const roleText = computed(() => (authStore.isSuper ? '超级管理员' : '普通管理员'))
|
||||
const pageTitle = computed(() => route.meta.title ?? '')
|
||||
|
||||
/* ---------------- 修改密码 ---------------- */
|
||||
const pwdVisible = ref(false)
|
||||
const pwdFormRef = ref<FormInstance>()
|
||||
const pwdLoading = ref(false)
|
||||
|
||||
const pwdForm = reactive({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
|
||||
const pwdRules: FormRules = {
|
||||
old_password: [
|
||||
{ required: true, message: '请输入原密码', trigger: 'blur' },
|
||||
{ min: 6, max: 64, message: '密码长度 6~64 位', trigger: 'blur' },
|
||||
],
|
||||
new_password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, max: 64, message: '密码长度 6~64 位', trigger: 'blur' },
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (value !== pwdForm.new_password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function openPwdDialog() {
|
||||
pwdForm.old_password = ''
|
||||
pwdForm.new_password = ''
|
||||
pwdForm.confirm_password = ''
|
||||
pwdVisible.value = true
|
||||
}
|
||||
|
||||
async function submitPassword() {
|
||||
const valid = await pwdFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
pwdLoading.value = true
|
||||
try {
|
||||
await changePassword({
|
||||
old_password: pwdForm.old_password,
|
||||
new_password: pwdForm.new_password,
|
||||
})
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
pwdVisible.value = false
|
||||
authStore.clearSession()
|
||||
router.replace('/login')
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
pwdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 登出 ---------------- */
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout(authStore.refreshToken)
|
||||
} catch {
|
||||
// 登出失败不阻塞本地清理
|
||||
}
|
||||
authStore.clearSession()
|
||||
router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="header-bar">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-btn" :size="20" @click="emit('toggle')">
|
||||
<Expand v-if="collapsed" />
|
||||
<Fold v-else />
|
||||
</el-icon>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item>管理后台</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="pageTitle">{{ pageTitle }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click">
|
||||
<div class="header-admin">
|
||||
<div class="admin-avatar">
|
||||
{{ displayName.slice(0, 1).toUpperCase() }}
|
||||
</div>
|
||||
<div class="admin-meta">
|
||||
<div class="admin-name">{{ displayName }}</div>
|
||||
<div class="admin-role">{{ roleText }}</div>
|
||||
</div>
|
||||
<el-icon class="admin-caret"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="openPwdDialog">
|
||||
<el-icon><Key /></el-icon>修改密码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog v-model="pwdVisible" title="修改密码" width="420px" :close-on-click-modal="false">
|
||||
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="88px">
|
||||
<el-form-item label="原密码" prop="old_password">
|
||||
<el-input v-model="pwdForm.old_password" type="password" show-password placeholder="请输入原密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="new_password">
|
||||
<el-input v-model="pwdForm.new_password" type="password" show-password placeholder="6~64 位新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirm_password">
|
||||
<el-input
|
||||
v-model="pwdForm.confirm_password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="pwdVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="pwdLoading" @click="submitPassword">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-bar {
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eef0f6;
|
||||
box-shadow: 0 1px 6px rgba(30, 36, 56, 0.04);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
cursor: pointer;
|
||||
color: #5d6685;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-admin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header-admin:hover {
|
||||
background: #f4f6fb;
|
||||
}
|
||||
|
||||
.admin-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #4f6ef7, #8b5cf6);
|
||||
}
|
||||
|
||||
.admin-meta {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2437;
|
||||
}
|
||||
|
||||
.admin-role {
|
||||
font-size: 11px;
|
||||
color: #9aa3c0;
|
||||
}
|
||||
|
||||
.admin-caret {
|
||||
color: #9aa3c0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineProps<{ collapsed: boolean }>()
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
interface MenuLeaf {
|
||||
path: string
|
||||
title: string
|
||||
icon: string
|
||||
superOnly?: boolean
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
groupTitle: string
|
||||
children: MenuLeaf[]
|
||||
}
|
||||
|
||||
const allGroups: MenuGroup[] = [
|
||||
{
|
||||
groupTitle: '总览',
|
||||
children: [{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' }],
|
||||
},
|
||||
{
|
||||
groupTitle: '业务管理',
|
||||
children: [
|
||||
{ path: '/users', title: '用户管理', icon: 'User' },
|
||||
{ path: '/goods', title: '商品管理', icon: 'Goods' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: '交易',
|
||||
children: [
|
||||
{ path: '/orders', title: '订单管理', icon: 'Tickets' },
|
||||
{ path: '/refunds', title: '退款审核', icon: 'RefreshLeft' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: '内容审核',
|
||||
children: [
|
||||
{ path: '/review/outfits', title: '穿搭审核', icon: 'Collection' },
|
||||
{ path: '/review/clothes', title: '衣物管理', icon: 'ShoppingBag' },
|
||||
{ path: '/review/shares', title: '衣柜分享', icon: 'Share' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupTitle: '运营',
|
||||
children: [{ path: '/notifications', title: '通知推送', icon: 'Bell' }],
|
||||
},
|
||||
{
|
||||
groupTitle: '系统',
|
||||
children: [{ path: '/admins', title: '管理员管理', icon: 'Setting', superOnly: true }],
|
||||
},
|
||||
]
|
||||
|
||||
/** 按角色过滤超管专属菜单 */
|
||||
const groups = computed(() =>
|
||||
allGroups
|
||||
.map((g) => ({
|
||||
...g,
|
||||
children: g.children.filter((c) => !c.superOnly || authStore.isSuper),
|
||||
}))
|
||||
.filter((g) => g.children.length > 0),
|
||||
)
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-scrollbar class="sidebar-scroll">
|
||||
<el-menu
|
||||
:default-active="activePath"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
unique-opened
|
||||
>
|
||||
<template v-for="group in groups" :key="group.groupTitle">
|
||||
<div v-show="!collapsed" class="menu-group-title">{{ group.groupTitle }}</div>
|
||||
<div v-show="collapsed" class="menu-group-divider"></div>
|
||||
<el-menu-item v-for="item in group.children" :key="item.path" :index="item.path">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-scroll {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sidebar-menu:not(.el-menu--collapse) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menu-group-title {
|
||||
padding: 16px 12px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: #5d6685;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-group-divider {
|
||||
height: 1px;
|
||||
margin: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
color: var(--zt-sidebar-text);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item:hover) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item.is-active) {
|
||||
background: linear-gradient(90deg, rgba(79, 110, 247, 0.9), rgba(139, 92, 246, 0.85));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(79, 110, 247, 0.35);
|
||||
}
|
||||
|
||||
.sidebar-menu.el-menu--collapse :deep(.el-menu-item) {
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import 'element-plus/dist/index.css'
|
||||
import './assets/element-theme.css'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/** 页面标题(面包屑与 document.title) */
|
||||
title?: string
|
||||
/** 是否需要登录 */
|
||||
requiresAuth?: boolean
|
||||
/** 是否仅超级管理员可见 */
|
||||
superOnly?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layout/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/dashboard/DashboardView.vue'),
|
||||
meta: { title: '仪表盘', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
component: () => import('@/views/user/UserListView.vue'),
|
||||
meta: { title: '用户管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'goods',
|
||||
name: 'goods',
|
||||
component: () => import('@/views/goods/GoodsListView.vue'),
|
||||
meta: { title: '商品管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'orders',
|
||||
component: () => import('@/views/order/OrderListView.vue'),
|
||||
meta: { title: '订单管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'refunds',
|
||||
name: 'refunds',
|
||||
component: () => import('@/views/order/RefundListView.vue'),
|
||||
meta: { title: '退款审核', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'review/outfits',
|
||||
name: 'review-outfits',
|
||||
component: () => import('@/views/review/OutfitListView.vue'),
|
||||
meta: { title: '穿搭审核', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'review/clothes',
|
||||
name: 'review-clothes',
|
||||
component: () => import('@/views/review/ClothesListView.vue'),
|
||||
meta: { title: '衣物管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'review/shares',
|
||||
name: 'review-shares',
|
||||
component: () => import('@/views/review/ShareListView.vue'),
|
||||
meta: { title: '衣柜分享', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('@/views/notification/NotifyView.vue'),
|
||||
meta: { title: '通知推送', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'admins',
|
||||
name: 'admins',
|
||||
component: () => import('@/views/admin/AdminListView.vue'),
|
||||
meta: { title: '管理员管理', requiresAuth: true, superOnly: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore()
|
||||
const token = authStore.accessToken
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.path === '/login' && token) {
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
// 超管专属路由:非超管重定向并提示
|
||||
if (to.meta.superOnly && token && !authStore.isSuper) {
|
||||
ElMessage.warning('该功能仅超级管理员可用')
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} · 纸条衣橱管理后台`
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,93 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { AdminInfo } from '@/types/auth'
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'zt_admin_access_token'
|
||||
const REFRESH_TOKEN_KEY = 'zt_admin_refresh_token'
|
||||
const ADMIN_INFO_KEY = 'zt_admin_info'
|
||||
const TOKEN_EXPIRE_KEY = 'zt_admin_expire_time'
|
||||
|
||||
export interface StoredAuth {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expireTime: number
|
||||
adminInfo: AdminInfo | null
|
||||
}
|
||||
|
||||
function loadStored(): StoredAuth {
|
||||
return {
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN_KEY) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY) ?? '',
|
||||
expireTime: Number(localStorage.getItem(TOKEN_EXPIRE_KEY) ?? '0'),
|
||||
adminInfo: parseAdminInfo(localStorage.getItem(ADMIN_INFO_KEY)),
|
||||
}
|
||||
}
|
||||
|
||||
function parseAdminInfo(raw: string | null): AdminInfo | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as AdminInfo
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const initial = loadStored()
|
||||
const accessToken = ref(initial.accessToken)
|
||||
const refreshToken = ref(initial.refreshToken)
|
||||
const expireTime = ref(initial.expireTime)
|
||||
const adminInfo = ref<AdminInfo | null>(initial.adminInfo)
|
||||
|
||||
const isLoggedIn = computed(() => accessToken.value !== '')
|
||||
const isSuper = computed(() => adminInfo.value?.role === 1)
|
||||
|
||||
/** 请求层(utils/request.ts)刷新成功后调用,仅同步 token,不改动 adminInfo */
|
||||
function applyTokens(tokens: { access_token: string; refresh_token: string; expire_time: number }) {
|
||||
accessToken.value = tokens.access_token
|
||||
refreshToken.value = tokens.refresh_token
|
||||
expireTime.value = tokens.expire_time
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
|
||||
localStorage.setItem(TOKEN_EXPIRE_KEY, String(tokens.expire_time))
|
||||
}
|
||||
|
||||
function setSession(payload: {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expire_time: number
|
||||
admin_info: AdminInfo
|
||||
}) {
|
||||
accessToken.value = payload.access_token
|
||||
refreshToken.value = payload.refresh_token
|
||||
expireTime.value = payload.expire_time
|
||||
adminInfo.value = payload.admin_info
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, payload.access_token)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refresh_token)
|
||||
localStorage.setItem(TOKEN_EXPIRE_KEY, String(payload.expire_time))
|
||||
localStorage.setItem(ADMIN_INFO_KEY, JSON.stringify(payload.admin_info))
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
expireTime.value = 0
|
||||
adminInfo.value = null
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||
localStorage.removeItem(ADMIN_INFO_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expireTime,
|
||||
adminInfo,
|
||||
isLoggedIn,
|
||||
isSuper,
|
||||
applyTokens,
|
||||
setSession,
|
||||
clearSession,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
/** 管理员信息(与后端 AdminInfo DTO 对齐) */
|
||||
export interface AdminInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
role: number
|
||||
status: number
|
||||
last_login_time: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface AdminLoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expire_time: number
|
||||
admin_info: AdminInfo
|
||||
}
|
||||
|
||||
export interface AdminRefreshTokenRequest {
|
||||
refresh_token: string
|
||||
access_token: string
|
||||
}
|
||||
|
||||
export interface AdminRefreshTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expire_time: number
|
||||
}
|
||||
|
||||
export interface AdminChangePasswordRequest {
|
||||
old_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export interface AdminCreateRequest {
|
||||
username: string
|
||||
password: string
|
||||
nickname?: string
|
||||
role: number
|
||||
}
|
||||
|
||||
export interface AdminUpdateRequest {
|
||||
nickname?: string
|
||||
password?: string
|
||||
role?: number
|
||||
status?: number
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 后端统一响应结构 */
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
details?: string[]
|
||||
}
|
||||
|
||||
/** 分页请求参数 */
|
||||
export interface PageQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/** 分页响应结构 */
|
||||
export interface PageResult<T> {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
list: T[]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface OutfitListItem {
|
||||
id: number
|
||||
outfit_uuid: string
|
||||
user_id: number
|
||||
outfit_name: string
|
||||
outfit_desc: string
|
||||
scene: string
|
||||
source: number
|
||||
is_public: number
|
||||
audit_status: number
|
||||
status: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface OutfitClothesItem {
|
||||
clothes_uuid: string
|
||||
name: string
|
||||
category_main: string
|
||||
category_sub: string
|
||||
processed_image: string
|
||||
image_process_status: number
|
||||
}
|
||||
|
||||
export interface OutfitDetailResponse extends OutfitListItem {
|
||||
items: OutfitClothesItem[]
|
||||
}
|
||||
|
||||
export interface OutfitListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
user_id?: number
|
||||
is_public?: number
|
||||
audit_status?: number
|
||||
status?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface OutfitAuditRequest {
|
||||
result: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface OutfitBanRequest {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ClothesItem {
|
||||
id: number
|
||||
clothes_uuid: string
|
||||
user_id: number
|
||||
name: string
|
||||
category_main: string
|
||||
category_sub: string
|
||||
style: string
|
||||
color_main: string
|
||||
color_sub: string
|
||||
thickness: string
|
||||
version: string
|
||||
material: string
|
||||
pattern: string
|
||||
sleeve_type: string
|
||||
clothes_type: string
|
||||
neckline_type: string
|
||||
formality: string
|
||||
occasions: string
|
||||
visual_impact: string
|
||||
remark: string
|
||||
processed_image: string
|
||||
image_size: number
|
||||
image_process_status: number
|
||||
fail_reason: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface ClothesListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
user_id?: number
|
||||
image_process_status?: number
|
||||
category_main?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface ClosetShareItem {
|
||||
id: number
|
||||
share_uuid: string
|
||||
user_id: number
|
||||
share_type: number
|
||||
expire_time: number
|
||||
status: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface ClosetShareListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
user_id?: number
|
||||
status?: number
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface DashboardContent {
|
||||
outfit: {
|
||||
total: number
|
||||
pending: number
|
||||
passed: number
|
||||
rejected: number
|
||||
banned: number
|
||||
}
|
||||
clothes: {
|
||||
total: number
|
||||
processing: number
|
||||
success: number
|
||||
failed: number
|
||||
}
|
||||
share: {
|
||||
total: number
|
||||
normal: number
|
||||
cancelled: number
|
||||
expired: number
|
||||
banned: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardVIP {
|
||||
member_total: number
|
||||
monthly: number
|
||||
yearly: number
|
||||
trial: number
|
||||
expiring_soon: number
|
||||
}
|
||||
|
||||
export interface GoodsTypeCount {
|
||||
type: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DashboardGoods {
|
||||
total: number
|
||||
on_shelf: number
|
||||
off_shelf: number
|
||||
by_type: GoodsTypeCount[]
|
||||
}
|
||||
|
||||
export interface DashboardUsage {
|
||||
ai_recommend: number
|
||||
closet_share: number
|
||||
outfit_share: number
|
||||
}
|
||||
|
||||
export interface DashboardSocial {
|
||||
cos_image_count: number
|
||||
like_count: number
|
||||
collect_count: number
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
users: {
|
||||
total: number
|
||||
today_new: number
|
||||
normal: number
|
||||
temp_ban: number
|
||||
perm_ban: number
|
||||
deactivated: number
|
||||
}
|
||||
orders: {
|
||||
total: number
|
||||
pending: number
|
||||
paid: number
|
||||
cancelled: number
|
||||
expired: number
|
||||
refunded: number
|
||||
}
|
||||
revenue: {
|
||||
total_revenue: number
|
||||
today_revenue: number
|
||||
refund_total: number
|
||||
}
|
||||
todo: {
|
||||
refund_pending: number
|
||||
outfit_pending_audit: number
|
||||
goods_on_shelf: number
|
||||
}
|
||||
content: DashboardContent
|
||||
vip: DashboardVIP
|
||||
goods: DashboardGoods
|
||||
usage: DashboardUsage
|
||||
social: DashboardSocial
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string
|
||||
new_users: number
|
||||
orders: number
|
||||
revenue: number
|
||||
}
|
||||
|
||||
export interface DashboardTrends {
|
||||
points: TrendPoint[]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface GoodsItem {
|
||||
id: number
|
||||
goods_code: string
|
||||
goods_name: string
|
||||
goods_type: number
|
||||
price: number
|
||||
discount_price: number
|
||||
member_days: number
|
||||
upload_quota: number
|
||||
ai_recommend_quota: number
|
||||
closet_share_quota: number
|
||||
outfit_share_quota: number
|
||||
valid_days: number
|
||||
description: string
|
||||
status: number
|
||||
product_id: string
|
||||
product_status: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface GoodsListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
goods_type?: number
|
||||
status?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface GoodsCreateRequest {
|
||||
goods_code: string
|
||||
goods_name: string
|
||||
goods_type: number
|
||||
price: number
|
||||
discount_price: number
|
||||
member_days?: number
|
||||
upload_quota?: number
|
||||
ai_recommend_quota?: number
|
||||
closet_share_quota?: number
|
||||
outfit_share_quota?: number
|
||||
valid_days?: number
|
||||
description?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface GoodsUpdateRequest {
|
||||
goods_name?: string
|
||||
price?: number
|
||||
discount_price?: number
|
||||
member_days?: number
|
||||
upload_quota?: number
|
||||
ai_recommend_quota?: number
|
||||
closet_share_quota?: number
|
||||
outfit_share_quota?: number
|
||||
valid_days?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GoodsStatusRequest {
|
||||
status: number
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface NotificationSendRequest {
|
||||
user_id: number
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface NotificationBroadcastRequest {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface NotificationBroadcastResponse {
|
||||
sent_count: number
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface OrderItem {
|
||||
order_no: string
|
||||
user_id: number
|
||||
goods_id: number
|
||||
goods_name: string
|
||||
unit_price: number
|
||||
total_amount: number
|
||||
pay_amount: number
|
||||
pay_channel: number
|
||||
order_status: number
|
||||
transaction_id: string
|
||||
member_days: number
|
||||
upload_quota: number
|
||||
ai_recommend_quota: number
|
||||
closet_share_quota: number
|
||||
outfit_share_quota: number
|
||||
expire_time: number
|
||||
pay_time: number
|
||||
refund_time: number
|
||||
refund_amount: number
|
||||
remark: string
|
||||
xpay_product_id: string
|
||||
xpay_platform: string
|
||||
xpay_env: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface OrderListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
order_no?: string
|
||||
user_id?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface RefundItem {
|
||||
refund_no: string
|
||||
order_no: string
|
||||
user_id: number
|
||||
refund_amount: number
|
||||
refund_reason: string
|
||||
status: number
|
||||
apply_time: number
|
||||
success_time: number
|
||||
fail_reason: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface RefundListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: number
|
||||
order_no?: string
|
||||
}
|
||||
|
||||
export interface RefundAuditRequest {
|
||||
reason: string
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export interface UserListItem {
|
||||
id: number
|
||||
user_uuid: string
|
||||
username: string
|
||||
avatar: string
|
||||
sex: number
|
||||
phone: string
|
||||
status: number
|
||||
ban_reason: string
|
||||
ban_start: number
|
||||
ban_expire: number
|
||||
vip_type: number
|
||||
vip_expire: number
|
||||
last_login_time: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface QuotaItem {
|
||||
quota_type: number
|
||||
total: number
|
||||
used: number
|
||||
remain: number
|
||||
}
|
||||
|
||||
export interface UserGrantItem {
|
||||
id: number
|
||||
quota_type: number
|
||||
amount: number
|
||||
used: number
|
||||
source: number
|
||||
expire_time: number
|
||||
ref_id: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface UserDetailResponse extends UserListItem {
|
||||
quotas: QuotaItem[]
|
||||
grants: UserGrantItem[]
|
||||
}
|
||||
|
||||
export interface UserListQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface UserBanRequest {
|
||||
ban_type: number
|
||||
ban_days?: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface UserUnbanRequest {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface UserQuotaAdjustRequest {
|
||||
quota_type: number
|
||||
amount: number
|
||||
expire_days: number
|
||||
}
|
||||
|
||||
export interface UserVIPAdjustRequest {
|
||||
vip_type: number
|
||||
vip_days: number
|
||||
reason: string
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { TagProps } from 'element-plus'
|
||||
|
||||
/** 枚举映射项:value=后端数值 label=中文文案 tagType=Tag 颜色 */
|
||||
export interface EnumMapItem {
|
||||
value: number
|
||||
label: string
|
||||
tagType?: TagProps['type']
|
||||
}
|
||||
|
||||
function enumMap(items: EnumMapItem[]): Record<number, EnumMapItem> {
|
||||
const map: Record<number, EnumMapItem> = {}
|
||||
for (const item of items) {
|
||||
map[item.value] = item
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/* ---------------- 管理员 ---------------- */
|
||||
export const ADMIN_ROLE = enumMap([
|
||||
{ value: 1, label: '超级管理员', tagType: 'danger' },
|
||||
{ value: 2, label: '普通管理员', tagType: 'info' },
|
||||
])
|
||||
|
||||
export const ADMIN_STATUS = enumMap([
|
||||
{ value: 1, label: '启用', tagType: 'success' },
|
||||
{ value: 2, label: '禁用', tagType: 'info' },
|
||||
])
|
||||
|
||||
/* ---------------- 用户 ---------------- */
|
||||
export const USER_STATUS = enumMap([
|
||||
{ value: 1, label: '正常', tagType: 'success' },
|
||||
{ value: 2, label: '临时禁用', tagType: 'warning' },
|
||||
{ value: 3, label: '永久禁用', tagType: 'danger' },
|
||||
{ value: 4, label: '已注销', tagType: 'info' },
|
||||
])
|
||||
|
||||
export const USER_SEX = enumMap([
|
||||
{ value: 1, label: '男' },
|
||||
{ value: 2, label: '女' },
|
||||
])
|
||||
|
||||
export const VIP_TYPE = enumMap([
|
||||
{ value: 1, label: '非会员', tagType: 'info' },
|
||||
{ value: 2, label: '月度会员', tagType: 'warning' },
|
||||
{ value: 3, label: '年度会员', tagType: 'danger' },
|
||||
{ value: 4, label: '体验会员', tagType: 'success' },
|
||||
])
|
||||
|
||||
export const QUOTA_TYPE = enumMap([
|
||||
{ value: 1, label: '上传额度' },
|
||||
{ value: 2, label: 'AI 推荐额度' },
|
||||
{ value: 3, label: '社区分享额度' },
|
||||
{ value: 4, label: '衣柜分享额度' },
|
||||
])
|
||||
|
||||
export const BAN_TYPE = enumMap([
|
||||
{ value: 1, label: '临时封禁' },
|
||||
{ value: 2, label: '永久封禁' },
|
||||
])
|
||||
|
||||
export const GRANT_SOURCE = enumMap([
|
||||
{ value: 1, label: '会员包' },
|
||||
{ value: 2, label: '加量包' },
|
||||
{ value: 3, label: '广告激励' },
|
||||
])
|
||||
|
||||
/* ---------------- 商品 ---------------- */
|
||||
export const GOODS_TYPE = enumMap([
|
||||
{ value: 1, label: '会员包', tagType: 'warning' },
|
||||
{ value: 2, label: '上传包' },
|
||||
{ value: 3, label: 'AI 推荐包' },
|
||||
{ value: 4, label: '衣柜分享包' },
|
||||
{ value: 5, label: '搭配分享包' },
|
||||
])
|
||||
|
||||
export const GOODS_STATUS = enumMap([
|
||||
{ value: 1, label: '上架', tagType: 'success' },
|
||||
{ value: 2, label: '下架', tagType: 'info' },
|
||||
])
|
||||
|
||||
export const PRODUCT_STATUS = enumMap([
|
||||
{ value: 0, label: '未发布', tagType: 'info' },
|
||||
{ value: 1, label: '发布中', tagType: 'warning' },
|
||||
{ value: 2, label: '已发布', tagType: 'success' },
|
||||
{ value: 3, label: '发布失败', tagType: 'danger' },
|
||||
])
|
||||
|
||||
/* ---------------- 订单 / 退款 ---------------- */
|
||||
export const ORDER_STATUS = enumMap([
|
||||
{ value: 1, label: '待支付', tagType: 'warning' },
|
||||
{ value: 2, label: '已支付', tagType: 'success' },
|
||||
{ value: 3, label: '已取消', tagType: 'info' },
|
||||
{ value: 4, label: '已过期', tagType: 'info' },
|
||||
{ value: 5, label: '已退款', tagType: 'danger' },
|
||||
])
|
||||
|
||||
export const PAY_CHANNEL = enumMap([
|
||||
{ value: 1, label: '微信支付' },
|
||||
{ value: 2, label: '微信虚拟支付' },
|
||||
])
|
||||
|
||||
export const XPAY_ENV = enumMap([
|
||||
{ value: 0, label: '现网' },
|
||||
{ value: 1, label: '沙箱', tagType: 'warning' },
|
||||
])
|
||||
|
||||
export const REFUND_STATUS = enumMap([
|
||||
{ value: 0, label: '待处理', tagType: 'warning' },
|
||||
{ value: 1, label: '退款中', tagType: 'primary' },
|
||||
{ value: 2, label: '退款成功', tagType: 'success' },
|
||||
{ value: 3, label: '退款失败', tagType: 'danger' },
|
||||
])
|
||||
|
||||
/* ---------------- 内容审核 ---------------- */
|
||||
export const OUTFIT_SOURCE = enumMap([
|
||||
{ value: 1, label: '用户创建' },
|
||||
{ value: 2, label: 'AI 推荐', tagType: 'primary' },
|
||||
])
|
||||
|
||||
export const OUTFIT_PUBLIC = enumMap([
|
||||
{ value: 1, label: '不公开', tagType: 'info' },
|
||||
{ value: 2, label: '公开', tagType: 'success' },
|
||||
])
|
||||
|
||||
export const OUTFIT_AUDIT_STATUS = enumMap([
|
||||
{ value: 1, label: '待审核', tagType: 'warning' },
|
||||
{ value: 2, label: '已通过', tagType: 'success' },
|
||||
{ value: 3, label: '未通过', tagType: 'danger' },
|
||||
])
|
||||
|
||||
export const OUTFIT_STATUS = enumMap([
|
||||
{ value: 1, label: '正常', tagType: 'success' },
|
||||
{ value: 2, label: '已封禁', tagType: 'danger' },
|
||||
{ value: 3, label: '待用户保存', tagType: 'warning' },
|
||||
])
|
||||
|
||||
export const SHARE_TYPE = enumMap([
|
||||
{ value: 1, label: '部分衣物' },
|
||||
{ value: 2, label: '全量快照' },
|
||||
])
|
||||
|
||||
export const SHARE_STATUS = enumMap([
|
||||
{ value: 1, label: '正常', tagType: 'success' },
|
||||
{ value: 2, label: '已取消', tagType: 'info' },
|
||||
{ value: 3, label: '已过期', tagType: 'info' },
|
||||
{ value: 4, label: '已封禁', tagType: 'danger' },
|
||||
])
|
||||
|
||||
export const IMAGE_PROCESS_STATUS = enumMap([
|
||||
{ value: 1, label: '抠图中', tagType: 'warning' },
|
||||
{ value: 2, label: '分类中', tagType: 'warning' },
|
||||
{ value: 3, label: '排队中', tagType: 'info' },
|
||||
{ value: 4, label: '处理成功', tagType: 'success' },
|
||||
{ value: 5, label: '处理失败', tagType: 'danger' },
|
||||
])
|
||||
|
||||
/* ---------------- 工具函数 ---------------- */
|
||||
|
||||
/** 根据枚举表取文案,未匹配返回 '-' */
|
||||
export function enumLabel(map: Record<number, EnumMapItem>, value?: number | null): string {
|
||||
if (value === undefined || value === null) return '-'
|
||||
const item = map[value]
|
||||
return item ? item.label : '-'
|
||||
}
|
||||
|
||||
/** 根据枚举表取 Tag 颜色 */
|
||||
export function enumTag(map: Record<number, EnumMapItem>, value?: number | null): TagProps['type'] {
|
||||
if (value === undefined || value === null) return 'info'
|
||||
const item = map[value]
|
||||
return item?.tagType ?? 'info'
|
||||
}
|
||||
|
||||
/** 枚举表转下拉选项(数字值) */
|
||||
export function enumOptions(map: Record<number, EnumMapItem>): { value: number; label: string }[] {
|
||||
return Object.values(map).map((item) => ({ value: item.value, label: item.label }))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** 金额:分 → 元字符串(保留两位小数) */
|
||||
export function formatMoney(cents?: number | null): string {
|
||||
if (cents === undefined || cents === null || Number.isNaN(cents)) return '-'
|
||||
const yuan = cents / 100
|
||||
return `${yuan.toFixed(2)}`
|
||||
}
|
||||
|
||||
/** 金额:元(字符串) → 分(整数),非法输入返回 0 */
|
||||
export function yuanToCents(yuan: number | string): number {
|
||||
const num = typeof yuan === 'string' ? Number.parseFloat(yuan) : yuan
|
||||
if (Number.isNaN(num) || num < 0) return 0
|
||||
return Math.round(num * 100)
|
||||
}
|
||||
|
||||
/** 时间戳(Unix 秒) → YYYY-MM-DD HH:mm:ss;0/空显示 '-' */
|
||||
export function formatDate(seconds?: number | null): string {
|
||||
if (!seconds || seconds <= 0) return '-'
|
||||
const d = new Date(seconds * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 时间戳(Unix 秒) → YYYY-MM-DD;0/空显示 '-' */
|
||||
export function formatDateOnly(seconds?: number | null): string {
|
||||
if (!seconds || seconds <= 0) return '-'
|
||||
const d = new Date(seconds * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 空字符串显示为 '-' */
|
||||
export function formatText(value?: string | null): string {
|
||||
return value === undefined || value === null || value === '' ? '-' : value
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import axios, { AxiosError, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ApiResponse } from '@/types/common'
|
||||
|
||||
/** 业务错误码:access token 过期(HTTP 401) */
|
||||
const CODE_TOKEN_TIMEOUT = 11002
|
||||
|
||||
/** 标记已尝试过 token 刷新的配置 */
|
||||
type RetriableConfig = InternalAxiosRequestConfig & { _retried?: boolean }
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
/* ---------------- 请求拦截:注入 Bearer token ---------------- */
|
||||
request.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('zt_admin_access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
/* ---------------- 响应拦截:业务码统一处理 + 双 token 刷新重放 ---------------- */
|
||||
let refreshingPromise: Promise<string | null> | null = null
|
||||
|
||||
/** 调用刷新接口换取新双 token;成功返回新 access token,失败返回 null */
|
||||
function refreshTokens(): Promise<string | null> {
|
||||
const refreshToken = localStorage.getItem('zt_admin_refresh_token')
|
||||
const oldAccessToken = localStorage.getItem('zt_admin_access_token')
|
||||
if (!refreshToken || !oldAccessToken) return Promise.resolve(null)
|
||||
|
||||
return axios
|
||||
.post<ApiResponse<{ access_token: string; refresh_token: string; expire_time: number }>>(
|
||||
'/api/v1/admin/refresh-token',
|
||||
{ refresh_token: refreshToken, access_token: oldAccessToken },
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.data.code !== 0 || !res.data.data) return null
|
||||
const tokens = res.data.data
|
||||
localStorage.setItem('zt_admin_access_token', tokens.access_token)
|
||||
localStorage.setItem('zt_admin_refresh_token', tokens.refresh_token)
|
||||
localStorage.setItem('zt_admin_expire_time', String(tokens.expire_time))
|
||||
return tokens.access_token
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
localStorage.removeItem('zt_admin_access_token')
|
||||
localStorage.removeItem('zt_admin_refresh_token')
|
||||
localStorage.removeItem('zt_admin_expire_time')
|
||||
localStorage.removeItem('zt_admin_info')
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse): AxiosResponse | Promise<AxiosResponse> => {
|
||||
const body = response.data as ApiResponse<unknown> | undefined
|
||||
if (body && typeof body.code === 'number') {
|
||||
// 业务成功:剥离包装,直接返回 data
|
||||
if (body.code === 0) {
|
||||
response.data = body.data
|
||||
return response
|
||||
}
|
||||
// access 过期:尝试刷新并重放
|
||||
const retriable = response.config as RetriableConfig
|
||||
if (body.code === CODE_TOKEN_TIMEOUT && !retriable._retried) {
|
||||
retriable._retried = true
|
||||
return handleTokenTimeout(retriable)
|
||||
}
|
||||
// 其余业务错误:提示后拒绝
|
||||
const msg = buildErrorMessage(body)
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(new ApiBizError(body.code, msg, body.details))
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiResponse<unknown>>) => {
|
||||
const body = error.response?.data
|
||||
const status = error.response?.status
|
||||
const code = body?.code
|
||||
|
||||
// access 过期(HTTP 401 且业务码 11002)
|
||||
if (status === 401 && code === CODE_TOKEN_TIMEOUT && error.config) {
|
||||
const retriable = error.config as RetriableConfig
|
||||
if (!retriable._retried) {
|
||||
retriable._retried = true
|
||||
return handleTokenTimeout(retriable)
|
||||
}
|
||||
}
|
||||
// 其余 401:会话失效,跳登录
|
||||
if (status === 401) {
|
||||
ElMessage.error(body?.msg ?? '登录已失效,请重新登录')
|
||||
redirectToLogin()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
// 403 无权限
|
||||
if (status === 403) {
|
||||
ElMessage.error(body?.msg ?? '无权限执行该操作')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
// 系统错误 5xx / 限流
|
||||
if (status && status >= 500) {
|
||||
ElMessage.error(body?.msg ?? '系统繁忙,请稍后重试')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (body && typeof body.code === 'number') {
|
||||
const msg = buildErrorMessage(body)
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(new ApiBizError(body.code, msg, body.details))
|
||||
}
|
||||
ElMessage.error('网络异常,请检查网络连接或后端服务是否启动')
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/** 统一错误文案:优先拼接 details 中的具体校验信息 */
|
||||
function buildErrorMessage(body: ApiResponse<unknown>): string {
|
||||
if (body.details && body.details.length > 0) {
|
||||
return body.details.join(';')
|
||||
}
|
||||
return body.msg || '操作失败'
|
||||
}
|
||||
|
||||
/** token 过期处理:单例刷新,所有并发请求等待同一个刷新结果后重放 */
|
||||
function handleTokenTimeout(config: RetriableConfig): Promise<AxiosResponse> {
|
||||
if (!refreshingPromise) {
|
||||
refreshingPromise = refreshTokens()
|
||||
}
|
||||
return refreshingPromise.then((newToken) => {
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`
|
||||
return request(config)
|
||||
}
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
redirectToLogin()
|
||||
return Promise.reject(new Error('token refresh failed'))
|
||||
})
|
||||
}
|
||||
|
||||
/** 业务错误对象,携带后端 code 与 details */
|
||||
export class ApiBizError extends Error {
|
||||
code: number
|
||||
details?: string[]
|
||||
constructor(code: number, message: string, details?: string[]) {
|
||||
super(message)
|
||||
this.name = 'ApiBizError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
/** 响应拦截器已将包装剥离为 data,此处直接取出业务数据 */
|
||||
function unwrap<T>(p: Promise<AxiosResponse>): Promise<T> {
|
||||
return p.then((res) => res.data as T)
|
||||
}
|
||||
|
||||
/** 通用 GET 请求(返回 data 部分) */
|
||||
export function httpGet<T>(url: string, params?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<T> {
|
||||
return unwrap<T>(request.get(url, { params, ...config }))
|
||||
}
|
||||
|
||||
/** 通用 POST 请求 */
|
||||
export function httpPost<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
return unwrap<T>(request.post(url, data, config))
|
||||
}
|
||||
|
||||
/** 通用 PUT 请求 */
|
||||
export function httpPut<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
return unwrap<T>(request.put(url, data, config))
|
||||
}
|
||||
|
||||
/** 通用 DELETE 请求 */
|
||||
export function httpDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return unwrap<T>(request.delete(url, config))
|
||||
}
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { createAdmin, deleteAdmin, getAdminList, updateAdmin } from '@/api/admin'
|
||||
import type { AdminInfo } from '@/types/auth'
|
||||
import { ADMIN_ROLE, ADMIN_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatText } from '@/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<AdminInfo[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAdminList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: query.status,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 创建 / 编辑 ---------------- */
|
||||
const dialogVisible = ref(false)
|
||||
const dialogLoading = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
role: 2,
|
||||
status: 1,
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入登录账号', trigger: 'blur' },
|
||||
{ min: 3, max: 32, message: '账号长度 3~32 位', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (editId.value === null) {
|
||||
if (!value) return callback(new Error('请输入初始密码'))
|
||||
if (value.length < 6 || value.length > 64) return callback(new Error('密码长度 6~64 位'))
|
||||
} else if (value && (value.length < 6 || value.length > 64)) {
|
||||
return callback(new Error('密码长度 6~64 位'))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
nickname: [{ max: 32, message: '昵称不超过 32 字', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, { username: '', password: '', nickname: '', role: 2, status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: AdminInfo) {
|
||||
editId.value = row.id
|
||||
Object.assign(form, {
|
||||
username: row.username,
|
||||
password: '',
|
||||
nickname: row.nickname,
|
||||
role: row.role,
|
||||
status: row.status,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
if (editId.value === null) {
|
||||
await createAdmin({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
nickname: form.nickname || undefined,
|
||||
role: form.role,
|
||||
})
|
||||
ElMessage.success('管理员创建成功')
|
||||
} else {
|
||||
await updateAdmin(editId.value, {
|
||||
nickname: form.nickname || undefined,
|
||||
password: form.password || undefined,
|
||||
role: form.role,
|
||||
status: form.status,
|
||||
})
|
||||
ElMessage.success('管理员更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 删除 ---------------- */
|
||||
async function handleDelete(row: AdminInfo) {
|
||||
if (row.id === authStore.adminInfo?.id) {
|
||||
ElMessage.warning('不能删除自己的账号')
|
||||
return
|
||||
}
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`删除后该管理员将无法登录(软删除),确认删除「${row.username}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', confirmButtonClass: 'el-button--danger' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await deleteAdmin(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-select v-model="query.status" placeholder="账号状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(ADMIN_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增管理员</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="username" label="登录账号" min-width="130" />
|
||||
<el-table-column label="昵称" min-width="120">
|
||||
<template #default="{ row }: { row: AdminInfo }">{{ formatText(row.nickname) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="110">
|
||||
<template #default="{ row }: { row: AdminInfo }">
|
||||
<el-tag :type="enumTag(ADMIN_ROLE, row.role)" size="small">
|
||||
{{ enumLabel(ADMIN_ROLE, row.role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }: { row: AdminInfo }">
|
||||
<el-tag :type="enumTag(ADMIN_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(ADMIN_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" width="150">
|
||||
<template #default="{ row }: { row: AdminInfo }">{{ formatDate(row.last_login_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: AdminInfo }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }: { row: AdminInfo }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="row.id === authStore.adminInfo?.id"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无管理员数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 创建 / 编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editId === null ? '新增管理员' : '编辑管理员'"
|
||||
width="460px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
|
||||
<el-form-item label="登录账号" prop="username">
|
||||
<el-input v-model="form.username" :disabled="editId !== null" placeholder="3~32 位账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="editId === null ? '初始密码(6~64 位)' : '留空则不修改密码'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="form.nickname" maxlength="32" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-select v-model="form.role" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in enumOptions(ADMIN_ROLE)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editId !== null" label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio v-for="opt in enumOptions(ADMIN_STATUS)" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="submitForm">
|
||||
{{ editId === null ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,711 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import BaseChart from '@/components/BaseChart.vue'
|
||||
import { getDashboardSummary, getDashboardTrends } from '@/api/dashboard'
|
||||
import type { DashboardSummary, DashboardTrends } from '@/types/dashboard'
|
||||
import { formatMoney } from '@/utils/format'
|
||||
import { GOODS_TYPE, enumLabel } from '@/utils/constants'
|
||||
|
||||
const loading = ref(false)
|
||||
const summary = ref<DashboardSummary | null>(null)
|
||||
const trendDays = ref<7 | 30>(7)
|
||||
const trend = ref<DashboardTrends | null>(null)
|
||||
|
||||
async function fetchSummary() {
|
||||
loading.value = true
|
||||
try {
|
||||
summary.value = await getDashboardSummary()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrends() {
|
||||
try {
|
||||
trend.value = await getDashboardTrends(trendDays.value)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSummary()
|
||||
fetchTrends()
|
||||
})
|
||||
|
||||
watch(trendDays, fetchTrends)
|
||||
|
||||
/* ---------- 图表配置 ---------- */
|
||||
|
||||
function pieOption(
|
||||
data: { name: string; value: number }[],
|
||||
colors: string[],
|
||||
): EChartsOption {
|
||||
return {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
icon: 'circle',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
textStyle: { color: '#5d6685', fontSize: 12 },
|
||||
},
|
||||
color: colors,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['48%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
label: { show: false },
|
||||
emphasis: { label: { show: false } },
|
||||
data,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const trendOption = computed<EChartsOption>(() => {
|
||||
const points = trend.value?.points ?? []
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['新增用户', '订单量', '营收(元)'],
|
||||
bottom: 0,
|
||||
icon: 'roundRect',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: '#5d6685' },
|
||||
},
|
||||
color: ['#4f6ef7', '#22c55e', '#f59e0b'],
|
||||
grid: { left: 56, right: 64, top: 40, bottom: 44 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: points.map((p) => p.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: '#e5e8f2' } },
|
||||
axisLabel: { color: '#9aa3c0' },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '数量',
|
||||
minInterval: 1,
|
||||
nameTextStyle: { color: '#9aa3c0' },
|
||||
axisLabel: { color: '#9aa3c0' },
|
||||
splitLine: { lineStyle: { color: '#eef1f8' } },
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '营收(元)',
|
||||
nameTextStyle: { color: '#9aa3c0' },
|
||||
axisLabel: { color: '#9aa3c0' },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '新增用户',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
data: points.map((p) => p.new_users),
|
||||
areaStyle: { opacity: 0.08 },
|
||||
},
|
||||
{
|
||||
name: '订单量',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
data: points.map((p) => p.orders),
|
||||
},
|
||||
{
|
||||
name: '营收(元)',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
yAxisIndex: 1,
|
||||
data: points.map((p) => +(p.revenue / 100).toFixed(2)),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const outfitPieOption = computed<EChartsOption>(() => {
|
||||
const o = summary.value?.content.outfit
|
||||
return pieOption(
|
||||
[
|
||||
{ name: '待审核', value: o?.pending ?? 0 },
|
||||
{ name: '已通过', value: o?.passed ?? 0 },
|
||||
{ name: '未通过', value: o?.rejected ?? 0 },
|
||||
],
|
||||
['#f59e0b', '#22c55e', '#ef4444'],
|
||||
)
|
||||
})
|
||||
|
||||
const clothesPieOption = computed<EChartsOption>(() => {
|
||||
const c = summary.value?.content.clothes
|
||||
return pieOption(
|
||||
[
|
||||
{ name: '处理中', value: c?.processing ?? 0 },
|
||||
{ name: '成功', value: c?.success ?? 0 },
|
||||
{ name: '失败', value: c?.failed ?? 0 },
|
||||
],
|
||||
['#06b6d4', '#22c55e', '#ef4444'],
|
||||
)
|
||||
})
|
||||
|
||||
const sharePieOption = computed<EChartsOption>(() => {
|
||||
const s = summary.value?.content.share
|
||||
return pieOption(
|
||||
[
|
||||
{ name: '正常', value: s?.normal ?? 0 },
|
||||
{ name: '已取消', value: s?.cancelled ?? 0 },
|
||||
{ name: '已过期', value: s?.expired ?? 0 },
|
||||
{ name: '已封禁', value: s?.banned ?? 0 },
|
||||
],
|
||||
['#22c55e', '#9aa3c0', '#cbd5e1', '#ef4444'],
|
||||
)
|
||||
})
|
||||
|
||||
const vipPieOption = computed<EChartsOption>(() => {
|
||||
const v = summary.value?.vip
|
||||
return pieOption(
|
||||
[
|
||||
{ name: '月度会员', value: v?.monthly ?? 0 },
|
||||
{ name: '年度会员', value: v?.yearly ?? 0 },
|
||||
{ name: '体验会员', value: v?.trial ?? 0 },
|
||||
],
|
||||
['#4f6ef7', '#8b5cf6', '#22c55e'],
|
||||
)
|
||||
})
|
||||
|
||||
const goodsTypePieOption = computed<EChartsOption>(() => {
|
||||
const list = summary.value?.goods.by_type ?? []
|
||||
return pieOption(
|
||||
list.map((item) => ({ name: enumLabel(GOODS_TYPE, item.type), value: item.count })),
|
||||
['#4f6ef7', '#06b6d4', '#8b5cf6', '#f59e0b', '#22c55e'],
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="dashboard">
|
||||
<!-- 欢迎横幅 -->
|
||||
<div class="hero-banner">
|
||||
<div>
|
||||
<h2 class="hero-title">纸条衣橱 · 运营总览</h2>
|
||||
<p class="hero-sub">掌握用户、交易与内容审核的核心数据动态</p>
|
||||
</div>
|
||||
<div class="hero-todo">
|
||||
<div class="todo-item">
|
||||
<span class="todo-num">{{ summary?.todo.refund_pending ?? 0 }}</span>
|
||||
<span class="todo-label">待处理退款</span>
|
||||
</div>
|
||||
<div class="todo-divider"></div>
|
||||
<div class="todo-item">
|
||||
<span class="todo-num">{{ summary?.todo.outfit_pending_audit ?? 0 }}</span>
|
||||
<span class="todo-label">待审核穿搭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 营收卡片 -->
|
||||
<div class="stat-row">
|
||||
<div class="stat-card revenue-card">
|
||||
<div class="stat-icon icon-revenue"><el-icon :size="22"><Money /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">累计营收(元)</div>
|
||||
<div class="stat-value">{{ formatMoney(summary?.revenue.total_revenue) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon icon-today"><el-icon :size="22"><TrendCharts /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">今日营收(元)</div>
|
||||
<div class="stat-value">{{ formatMoney(summary?.revenue.today_revenue) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon icon-refund"><el-icon :size="22"><RefreshLeft /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">累计退款(元)</div>
|
||||
<div class="stat-value">{{ formatMoney(summary?.revenue.refund_total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon icon-shelf"><el-icon :size="22"><ShoppingCart /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">上架商品数</div>
|
||||
<div class="stat-value">{{ summary?.todo.goods_on_shelf ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日消耗与内容互动 -->
|
||||
<div class="stat-row two-col">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">今日消耗</span>
|
||||
</div>
|
||||
<div class="mini-stat-grid">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-blue">{{ summary?.usage.ai_recommend ?? 0 }}</div>
|
||||
<div class="mini-stat-label">AI 推荐</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.usage.closet_share ?? 0 }}</div>
|
||||
<div class="mini-stat-label">衣柜分享</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.usage.outfit_share ?? 0 }}</div>
|
||||
<div class="mini-stat-label">社区分享</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">内容互动</span>
|
||||
</div>
|
||||
<div class="mini-stat-grid">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-green">{{ summary?.social.cos_image_count ?? 0 }}</div>
|
||||
<div class="mini-stat-label">COS 总图数</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-warn">{{ summary?.social.like_count ?? 0 }}</div>
|
||||
<div class="mini-stat-label">点赞数</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.social.collect_count ?? 0 }}</div>
|
||||
<div class="mini-stat-label">收藏数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据趋势 -->
|
||||
<div class="panel trend-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">数据趋势</span>
|
||||
<el-radio-group v-model="trendDays" size="small">
|
||||
<el-radio-button :value="7">近 7 天</el-radio-button>
|
||||
<el-radio-button :value="30">近 30 天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<BaseChart :option="trendOption" height="320px" />
|
||||
</div>
|
||||
|
||||
<!-- 三列分布 -->
|
||||
<div class="stat-row three-col">
|
||||
<!-- 内容审核概况 -->
|
||||
<div class="panel dist-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">内容审核概况</span>
|
||||
<el-tag size="small" type="warning" effect="light">待审穿搭 {{ summary?.content.outfit.pending ?? 0 }}</el-tag>
|
||||
</div>
|
||||
<div class="audit-block">
|
||||
<div class="audit-title">穿搭审核</div>
|
||||
<BaseChart :option="outfitPieOption" height="120px" />
|
||||
</div>
|
||||
<div class="audit-block">
|
||||
<div class="audit-title">衣物图片处理</div>
|
||||
<BaseChart :option="clothesPieOption" height="120px" />
|
||||
</div>
|
||||
<div class="audit-block">
|
||||
<div class="audit-title">衣柜分享</div>
|
||||
<BaseChart :option="sharePieOption" height="120px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VIP 会员概况 -->
|
||||
<div class="panel dist-panel dist-panel--center">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">VIP 会员概况</span>
|
||||
</div>
|
||||
<div class="mini-stat-grid">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.vip.member_total ?? 0 }}</div>
|
||||
<div class="mini-stat-label">有效会员</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-warn">{{ summary?.vip.expiring_soon ?? 0 }}</div>
|
||||
<div class="mini-stat-label">7 天内到期</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseChart :option="vipPieOption" height="220px" />
|
||||
</div>
|
||||
|
||||
<!-- 商品分类统计 -->
|
||||
<div class="panel dist-panel dist-panel--center">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">商品分类统计</span>
|
||||
</div>
|
||||
<div class="mini-stat-grid three">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">{{ summary?.goods.total ?? 0 }}</div>
|
||||
<div class="mini-stat-label">总数</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-green">{{ summary?.goods.on_shelf ?? 0 }}</div>
|
||||
<div class="mini-stat-label">上架</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value accent-gray">{{ summary?.goods.off_shelf ?? 0 }}</div>
|
||||
<div class="mini-stat-label">下架</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseChart :option="goodsTypePieOption" height="220px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<!-- 用户分布 -->
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">用户概况</span>
|
||||
<el-tag size="small" type="primary" effect="light">今日新增 {{ summary?.users.today_new ?? 0 }}</el-tag>
|
||||
</div>
|
||||
<div class="panel-main-number">{{ summary?.users.total ?? 0 }}</div>
|
||||
<div class="panel-sub">用户总数</div>
|
||||
<div class="dist-list">
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-green"></span>
|
||||
<span class="dist-label">正常</span>
|
||||
<span class="dist-value">{{ summary?.users.normal ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-orange"></span>
|
||||
<span class="dist-label">临时禁用</span>
|
||||
<span class="dist-value">{{ summary?.users.temp_ban ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-red"></span>
|
||||
<span class="dist-label">永久禁用</span>
|
||||
<span class="dist-value">{{ summary?.users.perm_ban ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-gray"></span>
|
||||
<span class="dist-label">已注销</span>
|
||||
<span class="dist-value">{{ summary?.users.deactivated ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单分布 -->
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">订单概况</span>
|
||||
<el-tag size="small" type="success" effect="light">已支付 {{ summary?.orders.paid ?? 0 }}</el-tag>
|
||||
</div>
|
||||
<div class="panel-main-number">{{ summary?.orders.total ?? 0 }}</div>
|
||||
<div class="panel-sub">订单总数</div>
|
||||
<div class="dist-list">
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-orange"></span>
|
||||
<span class="dist-label">待支付</span>
|
||||
<span class="dist-value">{{ summary?.orders.pending ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-blue"></span>
|
||||
<span class="dist-label">已取消</span>
|
||||
<span class="dist-value">{{ summary?.orders.cancelled ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-gray"></span>
|
||||
<span class="dist-label">已过期</span>
|
||||
<span class="dist-value">{{ summary?.orders.expired ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="dist-item">
|
||||
<span class="dist-dot dot-red"></span>
|
||||
<span class="dist-label">已退款</span>
|
||||
<span class="dist-value">{{ summary?.orders.refunded ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.hero-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
border-radius: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #4f6ef7 0%, #6a5cf0 55%, #8b5cf6 100%);
|
||||
box-shadow: 0 10px 26px rgba(79, 110, 247, 0.32);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-todo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.todo-num {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.todo-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.todo-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.three-col {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.two-col {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 22px rgba(30, 36, 56, 0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon-revenue {
|
||||
background: linear-gradient(135deg, #4f6ef7, #6a5cf0);
|
||||
}
|
||||
.icon-today {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
}
|
||||
.icon-refund {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
}
|
||||
.icon-shelf {
|
||||
background: linear-gradient(135deg, #06b6d4, #0ea5e9);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1f2437;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(30, 36, 56, 0.05);
|
||||
}
|
||||
|
||||
.trend-panel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dist-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dist-panel--center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2437;
|
||||
}
|
||||
|
||||
.panel-main-number {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: #1f2437;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.panel-sub {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.audit-block {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.audit-block:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.audit-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5d6685;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mini-stat-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
flex: 1;
|
||||
background: #f7f9fd;
|
||||
border-radius: 10px;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mini-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2437;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mini-stat-value.accent-warn {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.mini-stat-value.accent-green {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.mini-stat-value.accent-gray {
|
||||
color: #9aa3c0;
|
||||
}
|
||||
|
||||
.mini-stat-value.accent-blue {
|
||||
color: #4f6ef7;
|
||||
}
|
||||
|
||||
.mini-stat-label {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.dist-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #5d6685;
|
||||
}
|
||||
|
||||
.dist-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot-green {
|
||||
background: #22c55e;
|
||||
}
|
||||
.dot-orange {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.dot-red {
|
||||
background: #ef4444;
|
||||
}
|
||||
.dot-blue {
|
||||
background: #4f6ef7;
|
||||
}
|
||||
.dot-gray {
|
||||
background: #9aa3c0;
|
||||
}
|
||||
|
||||
.dist-value {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: #1f2437;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { createGoods, deleteGoods, getGoodsList, updateGoods, updateGoodsStatus } from '@/api/goods'
|
||||
import type { GoodsItem } from '@/types/goods'
|
||||
import { GOODS_STATUS, GOODS_TYPE, PRODUCT_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatMoney, formatText, yuanToCents } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<GoodsItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
goods_type: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getGoodsList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
goods_type: query.goods_type,
|
||||
status: query.status,
|
||||
keyword: query.keyword || undefined,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.goods_type = undefined
|
||||
query.status = undefined
|
||||
query.keyword = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 新增 / 编辑 ---------------- */
|
||||
const dialogVisible = ref(false)
|
||||
const dialogLoading = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
interface GoodsForm {
|
||||
goods_code: string
|
||||
goods_name: string
|
||||
goods_type: number
|
||||
price: number
|
||||
discount_price: number
|
||||
member_days: number
|
||||
upload_quota: number
|
||||
ai_recommend_quota: number
|
||||
closet_share_quota: number
|
||||
outfit_share_quota: number
|
||||
valid_days: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const form = reactive<GoodsForm>({
|
||||
goods_code: '',
|
||||
goods_name: '',
|
||||
goods_type: 1,
|
||||
price: 0,
|
||||
discount_price: 0,
|
||||
member_days: 0,
|
||||
upload_quota: 0,
|
||||
ai_recommend_quota: 0,
|
||||
closet_share_quota: 0,
|
||||
outfit_share_quota: 0,
|
||||
valid_days: 0,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
goods_code: [
|
||||
{ required: true, message: '请输入商品编码', trigger: 'blur' },
|
||||
{ max: 64, message: '编码不超过 64 字', trigger: 'blur' },
|
||||
],
|
||||
goods_name: [
|
||||
{ required: true, message: '请输入商品名称', trigger: 'blur' },
|
||||
{ max: 64, message: '名称不超过 64 字', trigger: 'blur' },
|
||||
],
|
||||
goods_type: [{ required: true, message: '请选择商品类型', trigger: 'change' }],
|
||||
price: [{ required: true, message: '请输入原价', trigger: 'blur' }],
|
||||
discount_price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
goods_code: '',
|
||||
goods_name: '',
|
||||
goods_type: 1,
|
||||
price: 0,
|
||||
discount_price: 0,
|
||||
member_days: 0,
|
||||
upload_quota: 0,
|
||||
ai_recommend_quota: 0,
|
||||
closet_share_quota: 0,
|
||||
outfit_share_quota: 0,
|
||||
valid_days: 0,
|
||||
description: '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: GoodsItem) {
|
||||
editId.value = row.id
|
||||
Object.assign(form, {
|
||||
goods_code: row.goods_code,
|
||||
goods_name: row.goods_name,
|
||||
goods_type: row.goods_type,
|
||||
price: row.price / 100,
|
||||
discount_price: row.discount_price / 100,
|
||||
member_days: row.member_days,
|
||||
upload_quota: row.upload_quota,
|
||||
ai_recommend_quota: row.ai_recommend_quota,
|
||||
closet_share_quota: row.closet_share_quota,
|
||||
outfit_share_quota: row.outfit_share_quota,
|
||||
valid_days: row.valid_days,
|
||||
description: row.description,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const price = yuanToCents(form.price)
|
||||
const discountPrice = yuanToCents(form.discount_price)
|
||||
if (editId.value === null) {
|
||||
await createGoods({
|
||||
goods_code: form.goods_code,
|
||||
goods_name: form.goods_name,
|
||||
goods_type: form.goods_type,
|
||||
price,
|
||||
discount_price: discountPrice,
|
||||
member_days: form.member_days,
|
||||
upload_quota: form.upload_quota,
|
||||
ai_recommend_quota: form.ai_recommend_quota,
|
||||
closet_share_quota: form.closet_share_quota,
|
||||
outfit_share_quota: form.outfit_share_quota,
|
||||
valid_days: form.valid_days,
|
||||
description: form.description || undefined,
|
||||
})
|
||||
ElMessage.success('商品创建成功')
|
||||
} else {
|
||||
await updateGoods(editId.value, {
|
||||
goods_name: form.goods_name,
|
||||
price,
|
||||
discount_price: discountPrice,
|
||||
member_days: form.member_days,
|
||||
upload_quota: form.upload_quota,
|
||||
ai_recommend_quota: form.ai_recommend_quota,
|
||||
closet_share_quota: form.closet_share_quota,
|
||||
outfit_share_quota: form.outfit_share_quota,
|
||||
valid_days: form.valid_days,
|
||||
description: form.description || undefined,
|
||||
})
|
||||
ElMessage.success('商品更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 上下架 / 删除 ---------------- */
|
||||
async function toggleStatus(row: GoodsItem) {
|
||||
const nextStatus = row.status === 1 ? 2 : 1
|
||||
const action = nextStatus === 2 ? '下架' : '上架'
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确认将商品「${row.goods_name}」${action}吗?`,
|
||||
`${action}确认`,
|
||||
{ type: 'warning' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await updateGoodsStatus(row.id, { status: nextStatus })
|
||||
ElMessage.success(`${action}成功`)
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleDelete(row: GoodsItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`删除后商品将不可见(软删除),确认删除「${row.goods_name}」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', confirmButtonClass: 'el-button--danger' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await deleteGoods(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-select v-model="query.goods_type" placeholder="商品类型" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(GOODS_TYPE)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="query.status" placeholder="状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(GOODS_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="商品名称 / 编码"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增商品</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="goods_code" label="商品编码" width="130" />
|
||||
<el-table-column prop="goods_name" label="商品名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="110">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<el-tag :type="enumTag(GOODS_TYPE, row.goods_type)" size="small">
|
||||
{{ enumLabel(GOODS_TYPE, row.goods_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原价" width="100" align="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<span class="price-original">¥{{ formatMoney(row.price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="售价" width="100" align="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<span class="zt-money">¥{{ formatMoney(row.discount_price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="member_days" label="会员天数" width="90" align="right" />
|
||||
<el-table-column label="上传额度" width="90" align="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">{{ row.upload_quota }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="AI 额度" width="90" align="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">{{ row.ai_recommend_quota }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="虚拟支付" width="110">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<el-tag v-if="row.product_id" :type="enumTag(PRODUCT_STATUS, row.product_status)" size="small">
|
||||
{{ enumLabel(PRODUCT_STATUS, row.product_status) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<el-tag :type="enumTag(GOODS_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(GOODS_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: GoodsItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }: { row: GoodsItem }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="row.status === 1" link type="warning" size="small" @click="toggleStatus(row)">
|
||||
下架
|
||||
</el-button>
|
||||
<el-button v-else link type="success" size="small" @click="toggleStatus(row)">上架</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无商品数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editId === null ? '新增商品' : '编辑商品'"
|
||||
width="640px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品编码" prop="goods_code">
|
||||
<el-input v-model="form.goods_code" :disabled="editId !== null" placeholder="唯一编码" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品名称" prop="goods_name">
|
||||
<el-input v-model="form.goods_name" placeholder="商品名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品类型" prop="goods_type">
|
||||
<el-select v-model="form.goods_type" style="width: 100%" :disabled="editId !== null">
|
||||
<el-option
|
||||
v-for="opt in enumOptions(GOODS_TYPE)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="会员天数">
|
||||
<el-input-number v-model="form.member_days" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="原价(元)" prop="price">
|
||||
<el-input-number v-model="form.price" :min="0" :precision="2" :step="0.01" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="售价(元)" prop="discount_price">
|
||||
<el-input-number v-model="form.discount_price" :min="0" :precision="2" :step="0.01" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="上传额度">
|
||||
<el-input-number v-model="form.upload_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="AI 推荐额度">
|
||||
<el-input-number v-model="form.ai_recommend_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="衣柜分享额度">
|
||||
<el-input-number v-model="form.closet_share_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="搭配分享额度">
|
||||
<el-input-number v-model="form.outfit_share_quota" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="有效期天数">
|
||||
<el-input-number v-model="form.valid_days" :min="0" :max="3650" controls-position="right" style="width: 100%" />
|
||||
<div class="form-tip">加量包有效期;会员包填 0 表示跟随 VIP</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="商品描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="submitForm">
|
||||
{{ editId === null ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
color: #9aa3c0;
|
||||
text-decoration: line-through;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Lock, User } from '@element-plus/icons-vue'
|
||||
import { login } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminLoginRequest } from '@/types/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive<AdminLoginRequest>({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules<AdminLoginRequest> = {
|
||||
username: [{ required: true, message: '请输入登录账号', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 64, message: '密码长度 6~64 位', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login({ username: form.username.trim(), password: form.password })
|
||||
authStore.setSession(res)
|
||||
ElMessage.success(`欢迎回来,${res.admin_info.nickname || res.admin_info.username}`)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
router.replace(redirect)
|
||||
} catch {
|
||||
// 错误提示已由请求层统一处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg-blob blob-a"></div>
|
||||
<div class="login-bg-blob blob-b"></div>
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="login-logo">
|
||||
<el-icon :size="30"><User /></el-icon>
|
||||
</div>
|
||||
<h1 class="login-title">纸条衣橱管理后台</h1>
|
||||
<p class="login-subtitle">ZhiTiao Closet Admin Console</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="登录账号" :prefix-icon="User" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-submit"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #1e2438 0%, #2c3560 55%, #4f6ef7 130%);
|
||||
}
|
||||
|
||||
.login-bg-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
opacity: 0.45;
|
||||
animation: blob-float 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.blob-a {
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
background: #4f6ef7;
|
||||
top: -120px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.blob-b {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
background: #8b5cf6;
|
||||
bottom: -120px;
|
||||
right: -80px;
|
||||
animation-delay: -6s;
|
||||
}
|
||||
|
||||
@keyframes blob-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(40px, -30px) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 400px;
|
||||
padding: 44px 40px 36px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 24px 64px rgba(10, 16, 40, 0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
margin: 0 auto 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4f6ef7, #8b5cf6);
|
||||
box-shadow: 0 8px 20px rgba(79, 110, 247, 0.4);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2437;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
letter-spacing: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Bell, Promotion } from '@element-plus/icons-vue'
|
||||
import { broadcastNotification, sendNotification } from '@/api/notification'
|
||||
|
||||
/* ---------------- 单用户通知 ---------------- */
|
||||
const singleFormRef = ref<FormInstance>()
|
||||
const singleLoading = ref(false)
|
||||
|
||||
const singleForm = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
title: '',
|
||||
content: '',
|
||||
})
|
||||
|
||||
const singleRules: FormRules = {
|
||||
user_id: [{ required: true, message: '请输入用户 ID', trigger: 'blur' }],
|
||||
title: [
|
||||
{ required: true, message: '请输入通知标题', trigger: 'blur' },
|
||||
{ max: 64, message: '标题不超过 64 字', trigger: 'blur' },
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入通知内容', trigger: 'blur' },
|
||||
{ max: 500, message: '内容不超过 500 字', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function submitSingle() {
|
||||
const valid = await singleFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !singleForm.user_id) return
|
||||
singleLoading.value = true
|
||||
try {
|
||||
await sendNotification({
|
||||
user_id: singleForm.user_id,
|
||||
title: singleForm.title,
|
||||
content: singleForm.content,
|
||||
})
|
||||
ElMessage.success('通知已发送')
|
||||
singleForm.title = ''
|
||||
singleForm.content = ''
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
singleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 全量广播 ---------------- */
|
||||
const broadcastFormRef = ref<FormInstance>()
|
||||
const broadcastLoading = ref(false)
|
||||
|
||||
const broadcastForm = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
})
|
||||
|
||||
const broadcastRules: FormRules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入通知标题', trigger: 'blur' },
|
||||
{ max: 64, message: '标题不超过 64 字', trigger: 'blur' },
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入通知内容', trigger: 'blur' },
|
||||
{ max: 500, message: '内容不超过 500 字', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function submitBroadcast() {
|
||||
const valid = await broadcastFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
'全量广播将向所有用户推送该通知(分批写入),确认发送吗?',
|
||||
'广播确认',
|
||||
{ type: 'warning', confirmButtonText: '确认广播' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
broadcastLoading.value = true
|
||||
try {
|
||||
const res = await broadcastNotification({
|
||||
title: broadcastForm.title,
|
||||
content: broadcastForm.content,
|
||||
})
|
||||
ElMessage.success(`广播成功,已推送 ${res.sent_count} 位用户`)
|
||||
broadcastForm.title = ''
|
||||
broadcastForm.content = ''
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
broadcastLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notify-page">
|
||||
<div class="notify-grid">
|
||||
<!-- 单用户通知 -->
|
||||
<div class="zt-card notify-card">
|
||||
<div class="card-head">
|
||||
<div class="card-head-icon icon-single"><el-icon :size="20"><Bell /></el-icon></div>
|
||||
<div>
|
||||
<div class="zt-card-title" style="margin-bottom: 2px">单用户通知</div>
|
||||
<div class="card-sub">向指定用户发送系统通知,实时送达小程序</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="singleFormRef" :model="singleForm" :rules="singleRules" label-width="80px">
|
||||
<el-form-item label="用户 ID" prop="user_id">
|
||||
<el-input-number
|
||||
v-model="singleForm.user_id"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
placeholder="请输入用户 ID"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="singleForm.title" maxlength="64" show-word-limit placeholder="通知标题(≤64 字)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="singleForm.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="通知内容(≤500 字)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Bell" :loading="singleLoading" @click="submitSingle">
|
||||
发送通知
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 全量广播 -->
|
||||
<div class="zt-card notify-card">
|
||||
<div class="card-head">
|
||||
<div class="card-head-icon icon-broadcast"><el-icon :size="20"><Promotion /></el-icon></div>
|
||||
<div>
|
||||
<div class="zt-card-title" style="margin-bottom: 2px">全量广播</div>
|
||||
<div class="card-sub">向全部用户推送,分批写入并同步未读计数</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="broadcastFormRef" :model="broadcastForm" :rules="broadcastRules" label-width="80px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="broadcastForm.title" maxlength="64" show-word-limit placeholder="通知标题(≤64 字)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="broadcastForm.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="通知内容(≤500 字)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="danger" :icon="Promotion" :loading="broadcastLoading" @click="submitBroadcast">
|
||||
全量广播
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notify-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.notify-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card-head-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon-single {
|
||||
background: linear-gradient(135deg, #4f6ef7, #6a5cf0);
|
||||
}
|
||||
|
||||
.icon-broadcast {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { getOrderDetail, getOrderList } from '@/api/order'
|
||||
import type { OrderItem } from '@/types/order'
|
||||
import { ORDER_STATUS, PAY_CHANNEL, XPAY_ENV, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatMoney, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<OrderItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
order_no: '',
|
||||
user_id: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOrderList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
order_no: query.order_no || undefined,
|
||||
user_id: query.user_id || undefined,
|
||||
status: query.status,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.order_no = ''
|
||||
query.user_id = undefined
|
||||
query.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 详情抽屉 ---------------- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<OrderItem | null>(null)
|
||||
|
||||
async function openDetail(row: OrderItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getOrderDetail(row.order_no)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model="query.order_no"
|
||||
placeholder="订单号(精确)"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model.number="query.user_id"
|
||||
placeholder="用户 ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="订单状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(ORDER_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column prop="goods_name" label="商品" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="实付金额" width="110" align="right">
|
||||
<template #default="{ row }: { row: OrderItem }">
|
||||
<span class="zt-money">¥{{ formatMoney(row.pay_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }: { row: OrderItem }">
|
||||
<el-tag :type="enumTag(ORDER_STATUS, row.order_status)" size="small">
|
||||
{{ enumLabel(ORDER_STATUS, row.order_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付渠道" width="100">
|
||||
<template #default="{ row }: { row: OrderItem }">
|
||||
{{ enumLabel(PAY_CHANNEL, row.pay_channel) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付时间" width="150">
|
||||
<template #default="{ row }: { row: OrderItem }">{{ formatDate(row.pay_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: OrderItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }: { row: OrderItem }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无订单数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 订单详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="订单详情" size="480px">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<el-descriptions v-if="detail" :column="1" border size="small">
|
||||
<el-descriptions-item label="订单号">{{ detail.order_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名称">{{ formatText(detail.goods_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="enumTag(ORDER_STATUS, detail.order_status)" size="small">
|
||||
{{ enumLabel(ORDER_STATUS, detail.order_status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付渠道">
|
||||
{{ enumLabel(PAY_CHANNEL, detail.pay_channel) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="微信交易号">{{ formatText(detail.transaction_id) }}</el-descriptions-item>
|
||||
<template v-if="detail.xpay_product_id">
|
||||
<el-descriptions-item label="虚拟支付道具 ID">{{ formatText(detail.xpay_product_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟支付平台">{{ formatText(detail.xpay_platform) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟支付环境">{{ enumLabel(XPAY_ENV, detail.xpay_env) }}</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item label="单价">
|
||||
<span class="zt-money">¥{{ formatMoney(detail.unit_price) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单总额">
|
||||
<span class="zt-money">¥{{ formatMoney(detail.total_amount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="实付金额">
|
||||
<span class="zt-money">¥{{ formatMoney(detail.pay_amount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="退款金额">
|
||||
<span class="zt-money">¥{{ formatMoney(detail.refund_amount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="退款时间">{{ formatDate(detail.refund_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会员天数">{{ detail.member_days }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上传额度">{{ detail.upload_quota }}</el-descriptions-item>
|
||||
<el-descriptions-item label="AI 推荐额度">{{ detail.ai_recommend_quota }}</el-descriptions-item>
|
||||
<el-descriptions-item label="衣柜分享额度">{{ detail.closet_share_quota }}</el-descriptions-item>
|
||||
<el-descriptions-item label="搭配分享额度">{{ detail.outfit_share_quota }}</el-descriptions-item>
|
||||
<el-descriptions-item label="权益到期">{{ formatDate(detail.expire_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付时间">{{ formatDate(detail.pay_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ formatText(detail.remark) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { approveRefund, getRefundList, rejectRefund } from '@/api/order'
|
||||
import type { RefundItem } from '@/types/order'
|
||||
import { REFUND_STATUS, enumLabel, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatMoney, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<RefundItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
/**
|
||||
* 状态筛选:undefined = 全部(不传),0 = 待处理(显式传 0)
|
||||
* 后端用指针区分「不传」与「传 0」,前端用 -1 作为「全部」的占位值
|
||||
*/
|
||||
const query = reactive({
|
||||
status: -1 as number,
|
||||
order_no: '',
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ value: -1, label: '全部状态' },
|
||||
{ value: 0, label: '待处理' },
|
||||
{ value: 1, label: '退款中' },
|
||||
{ value: 2, label: '退款成功' },
|
||||
{ value: 3, label: '退款失败' },
|
||||
]
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRefundList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: query.status === -1 ? undefined : query.status,
|
||||
order_no: query.order_no || undefined,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.status = -1
|
||||
query.order_no = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 审核(通过 / 驳回) ---------------- */
|
||||
const auditVisible = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const auditMode = ref<'approve' | 'reject'>('approve')
|
||||
const auditTarget = ref<RefundItem | null>(null)
|
||||
const auditFormRef = ref<FormInstance>()
|
||||
|
||||
const auditForm = reactive({
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const auditRules: FormRules = {
|
||||
reason: [
|
||||
{ required: true, message: '请填写审核备注', trigger: 'blur' },
|
||||
{ max: 200, message: '备注不超过 200 字', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
function openAudit(mode: 'approve' | 'reject', row: RefundItem) {
|
||||
auditMode.value = mode
|
||||
auditTarget.value = row
|
||||
auditForm.reason = ''
|
||||
auditVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAudit() {
|
||||
const valid = await auditFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !auditTarget.value) return
|
||||
auditLoading.value = true
|
||||
try {
|
||||
if (auditMode.value === 'approve') {
|
||||
await approveRefund(auditTarget.value.refund_no, { reason: auditForm.reason })
|
||||
ElMessage.success('已通过,退款单进入「退款中」状态')
|
||||
} else {
|
||||
await rejectRefund(auditTarget.value.refund_no, { reason: auditForm.reason })
|
||||
ElMessage.success('已驳回')
|
||||
}
|
||||
auditVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReject(row: RefundItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`驳回后该退款单将标记为「退款失败」,确认驳回订单 ${row.order_no} 的退款吗?`,
|
||||
'驳回确认',
|
||||
{ type: 'warning', confirmButtonText: '继续驳回' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
openAudit('reject', row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-select v-model="query.status" placeholder="退款状态">
|
||||
<el-option
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.order_no"
|
||||
placeholder="关联订单号"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="refund_no" label="退款单号" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="order_no" label="关联订单号" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column label="退款金额" width="110" align="right">
|
||||
<template #default="{ row }: { row: RefundItem }">
|
||||
<span class="zt-money">¥{{ formatMoney(row.refund_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: RefundItem }">
|
||||
<el-tag :type="enumTag(REFUND_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(REFUND_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请原因" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }: { row: RefundItem }">{{ formatText(row.refund_reason) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" width="150">
|
||||
<template #default="{ row }: { row: RefundItem }">{{ formatDate(row.apply_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败原因" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }: { row: RefundItem }">{{ formatText(row.fail_reason) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }: { row: RefundItem }">
|
||||
<template v-if="row.status === 0">
|
||||
<el-button link type="success" size="small" @click="openAudit('approve', row)">通过</el-button>
|
||||
<el-button link type="danger" size="small" @click="confirmReject(row)">驳回</el-button>
|
||||
</template>
|
||||
<span v-else class="no-action">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无退款单数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 审核对话框 -->
|
||||
<el-dialog
|
||||
v-model="auditVisible"
|
||||
:title="auditMode === 'approve' ? '退款审核通过' : '退款审核驳回'"
|
||||
width="460px"
|
||||
>
|
||||
<el-alert
|
||||
v-if="auditTarget"
|
||||
:title="`退款单 ${auditTarget.refund_no} · 金额 ¥${formatMoney(auditTarget.refund_amount)}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="audit-alert"
|
||||
/>
|
||||
<el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="88px">
|
||||
<el-form-item :label="auditMode === 'approve' ? '审核备注' : '驳回原因'" prop="reason">
|
||||
<el-input
|
||||
v-model="auditForm.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
:placeholder="auditMode === 'approve' ? '如:已核实,同意退款(必填)' : '请填写驳回原因(必填)'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible = false">取消</el-button>
|
||||
<el-button
|
||||
:type="auditMode === 'approve' ? 'success' : 'danger'"
|
||||
:loading="auditLoading"
|
||||
@click="submitAudit"
|
||||
>
|
||||
{{ auditMode === 'approve' ? '确认通过' : '确认驳回' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.no-action {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { getClothesDetail, getClothesList } from '@/api/content'
|
||||
import type { ClothesItem } from '@/types/content'
|
||||
import { IMAGE_PROCESS_STATUS, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<ClothesItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
image_process_status: undefined as number | undefined,
|
||||
category_main: '',
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getClothesList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
user_id: query.user_id || undefined,
|
||||
image_process_status: query.image_process_status,
|
||||
category_main: query.category_main || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.user_id = undefined
|
||||
query.image_process_status = undefined
|
||||
query.category_main = ''
|
||||
query.keyword = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 详情抽屉 ---------------- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<ClothesItem | null>(null)
|
||||
|
||||
async function openDetail(row: ClothesItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getClothesDetail(row.clothes_uuid)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model.number="query.user_id"
|
||||
placeholder="用户 ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.image_process_status" placeholder="图片处理状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(IMAGE_PROCESS_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.category_main"
|
||||
placeholder="主类目"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="衣物名称 / 描述"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="衣物图片" width="80">
|
||||
<template #default="{ row }: { row: ClothesItem }">
|
||||
<el-image
|
||||
v-if="row.processed_image"
|
||||
:src="row.processed_image"
|
||||
:preview-src-list="[row.processed_image]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="clothes-thumb"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column prop="category_main" label="主类目" width="100" />
|
||||
<el-table-column prop="category_sub" label="子类目" width="100" />
|
||||
<el-table-column label="处理状态" width="100">
|
||||
<template #default="{ row }: { row: ClothesItem }">
|
||||
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, row.image_process_status)" size="small">
|
||||
{{ enumLabel(IMAGE_PROCESS_STATUS, row.image_process_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: ClothesItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }: { row: ClothesItem }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无衣物数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 衣物详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="衣物详情" size="520px">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-image">
|
||||
<el-image
|
||||
v-if="detail.processed_image"
|
||||
:src="detail.processed_image"
|
||||
:preview-src-list="[detail.processed_image]"
|
||||
preview-teleported
|
||||
fit="contain"
|
||||
class="detail-img"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder large">
|
||||
<el-icon :size="32"><Picture /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="名称" :span="2">{{ formatText(detail.name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主类目">{{ formatText(detail.category_main) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="子类目">{{ formatText(detail.category_sub) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风格">{{ formatText(detail.style) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主色">{{ formatText(detail.color_main) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="副色">{{ formatText(detail.color_sub) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="厚薄">{{ formatText(detail.thickness) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="版型">{{ formatText(detail.version) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="材质">{{ formatText(detail.material) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="图案">{{ formatText(detail.pattern) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="袖型">{{ formatText(detail.sleeve_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="衣型">{{ formatText(detail.clothes_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="领型">{{ formatText(detail.neckline_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="正式度">{{ formatText(detail.formality) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="场合">{{ formatText(detail.occasions) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="视觉冲击">{{ formatText(detail.visual_impact) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="图片大小">{{ detail.image_size }} KB</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">
|
||||
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, detail.image_process_status)" size="small">
|
||||
{{ enumLabel(IMAGE_PROCESS_STATUS, detail.image_process_status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="失败原因">{{ formatText(detail.fail_reason) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ formatText(detail.remark) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clothes-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f4f6fb;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.thumb-placeholder.large {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-img {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
border-radius: 12px;
|
||||
background: #f4f6fb;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,349 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { auditOutfit, banOutfit, getOutfitDetail, getOutfitList, unbanOutfit } from '@/api/content'
|
||||
import type { OutfitDetailResponse, OutfitListItem } from '@/types/content'
|
||||
import {
|
||||
IMAGE_PROCESS_STATUS,
|
||||
OUTFIT_AUDIT_STATUS,
|
||||
OUTFIT_PUBLIC,
|
||||
OUTFIT_SOURCE,
|
||||
OUTFIT_STATUS,
|
||||
enumLabel,
|
||||
enumOptions,
|
||||
enumTag,
|
||||
} from '@/utils/constants'
|
||||
import { formatDate, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<OutfitListItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
is_public: undefined as number | undefined,
|
||||
audit_status: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOutfitList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
user_id: query.user_id || undefined,
|
||||
is_public: query.is_public,
|
||||
audit_status: query.audit_status,
|
||||
status: query.status,
|
||||
keyword: query.keyword || undefined,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.user_id = undefined
|
||||
query.is_public = undefined
|
||||
query.audit_status = undefined
|
||||
query.status = undefined
|
||||
query.keyword = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 详情抽屉 ---------------- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<OutfitDetailResponse | null>(null)
|
||||
|
||||
async function openDetail(row: OutfitListItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getOutfitDetail(row.outfit_uuid)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 审核 ---------------- */
|
||||
const auditVisible = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const auditTarget = ref<OutfitListItem | null>(null)
|
||||
const auditFormRef = ref<FormInstance>()
|
||||
|
||||
const auditForm = reactive({
|
||||
result: 1,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const auditRules: FormRules = {
|
||||
result: [{ required: true, message: '请选择审核结果', trigger: 'change' }],
|
||||
reason: [
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (auditForm.result === 2 && !value.trim()) {
|
||||
callback(new Error('驳回时必须填写原因'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function openAudit(row: OutfitListItem) {
|
||||
auditTarget.value = row
|
||||
auditForm.result = 1
|
||||
auditForm.reason = ''
|
||||
auditVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAudit() {
|
||||
const valid = await auditFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !auditTarget.value) return
|
||||
auditLoading.value = true
|
||||
try {
|
||||
await auditOutfit(auditTarget.value.outfit_uuid, {
|
||||
result: auditForm.result,
|
||||
reason: auditForm.result === 2 ? auditForm.reason : undefined,
|
||||
})
|
||||
ElMessage.success(auditForm.result === 1 ? '审核通过' : '已驳回')
|
||||
auditVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 封禁 / 解封 ---------------- */
|
||||
async function handleBan(row: OutfitListItem) {
|
||||
const result = (await ElMessageBox.prompt('封禁原因(选填)', `封禁穿搭「${row.outfit_name}」`, {
|
||||
confirmButtonText: '确认封禁',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '选填,最多 200 字',
|
||||
inputValidator: (val: string) => (val.length <= 200 ? true : '原因不能超过 200 字'),
|
||||
}).catch(() => null)) as { value: string } | null
|
||||
if (!result) return
|
||||
const reason = result.value
|
||||
await banOutfit(row.outfit_uuid, { reason: reason || undefined })
|
||||
ElMessage.success('封禁成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleUnban(row: OutfitListItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确认解封穿搭「${row.outfit_name}」吗?`,
|
||||
'解封确认',
|
||||
{ type: 'warning' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await unbanOutfit(row.outfit_uuid)
|
||||
ElMessage.success('解封成功')
|
||||
fetchList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model.number="query.user_id"
|
||||
placeholder="用户 ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.is_public" placeholder="公开状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(OUTFIT_PUBLIC)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="query.audit_status" placeholder="审核状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(OUTFIT_AUDIT_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="query.status" placeholder="穿搭状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(OUTFIT_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="穿搭名称 / 描述"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="outfit_name" label="穿搭名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column label="来源" width="100">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
<el-tag :type="enumTag(OUTFIT_SOURCE, row.source)" size="small">
|
||||
{{ enumLabel(OUTFIT_SOURCE, row.source) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="公开" width="80">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
{{ enumLabel(OUTFIT_PUBLIC, row.is_public) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="100">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
<el-tag :type="enumTag(OUTFIT_AUDIT_STATUS, row.audit_status)" size="small">
|
||||
{{ enumLabel(OUTFIT_AUDIT_STATUS, row.audit_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
<el-tag :type="enumTag(OUTFIT_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(OUTFIT_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="场景" min-width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }: { row: OutfitListItem }">{{ formatText(row.scene) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: OutfitListItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: OutfitListItem }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.audit_status === 1" link type="warning" size="small" @click="openAudit(row)">
|
||||
审核
|
||||
</el-button>
|
||||
<el-button v-if="row.status === 1" link type="danger" size="small" @click="handleBan(row)">封禁</el-button>
|
||||
<el-button v-if="row.status === 2" link type="success" size="small" @click="handleUnban(row)">解封</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无穿搭数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 穿搭详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="穿搭详情" size="520px">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="名称">{{ formatText(detail.outfit_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述">{{ formatText(detail.outfit_desc) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="场景">{{ formatText(detail.scene) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户 ID">{{ detail.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源">{{ enumLabel(OUTFIT_SOURCE, detail.source) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公开状态">
|
||||
{{ enumLabel(OUTFIT_PUBLIC, detail.is_public) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核状态">
|
||||
<el-tag :type="enumTag(OUTFIT_AUDIT_STATUS, detail.audit_status)" size="small">
|
||||
{{ enumLabel(OUTFIT_AUDIT_STATUS, detail.audit_status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="enumTag(OUTFIT_STATUS, detail.status)" size="small">
|
||||
{{ enumLabel(OUTFIT_STATUS, detail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 class="section-title">衣物明细({{ detail.items.length }})</h4>
|
||||
<el-table :data="detail.items" size="small" border>
|
||||
<el-table-column prop="name" label="衣物名称" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="category_main" label="主类目" width="90" />
|
||||
<el-table-column prop="category_sub" label="子类目" width="90" />
|
||||
<el-table-column label="图片状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="enumTag(IMAGE_PROCESS_STATUS, row.image_process_status)" size="small">
|
||||
{{ enumLabel(IMAGE_PROCESS_STATUS, row.image_process_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 审核对话框 -->
|
||||
<el-dialog v-model="auditVisible" :title="`穿搭审核 · ${auditTarget?.outfit_name ?? ''}`" width="460px">
|
||||
<el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="88px">
|
||||
<el-form-item label="审核结果" prop="result">
|
||||
<el-radio-group v-model="auditForm.result">
|
||||
<el-radio :value="1">通过</el-radio>
|
||||
<el-radio :value="2">驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auditForm.result === 2" label="驳回原因" prop="reason">
|
||||
<el-input
|
||||
v-model="auditForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="驳回时必填原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible = false">取消</el-button>
|
||||
<el-button :type="auditForm.result === 1 ? 'success' : 'danger'" :loading="auditLoading" @click="submitAudit">
|
||||
{{ auditForm.result === 1 ? '确认通过' : '确认驳回' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 14px;
|
||||
color: #1f2437;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { banClosetShare, getClosetShareList, unbanClosetShare } from '@/api/content'
|
||||
import type { ClosetShareItem } from '@/types/content'
|
||||
import { SHARE_STATUS, SHARE_TYPE, enumLabel, enumOptions, enumTag } from '@/utils/constants'
|
||||
import { formatDate } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<ClosetShareItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
user_id: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getClosetShareList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
user_id: query.user_id || undefined,
|
||||
status: query.status,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.user_id = undefined
|
||||
query.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 封禁 / 解封 ---------------- */
|
||||
async function handleBan(row: ClosetShareItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`封禁后该分享将对外不可见,确认封禁分享 ${row.share_uuid} 吗?`,
|
||||
'封禁确认',
|
||||
{ type: 'warning', confirmButtonText: '确认封禁' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await banClosetShare(row.share_uuid)
|
||||
ElMessage.success('封禁成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
async function handleUnban(row: ClosetShareItem) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确认解封分享 ${row.share_uuid} 吗?`,
|
||||
'解封确认',
|
||||
{ type: 'warning' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await unbanClosetShare(row.share_uuid)
|
||||
ElMessage.success('解封成功')
|
||||
fetchList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model.number="query.user_id"
|
||||
placeholder="用户 ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="分享状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(SHARE_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="share_uuid" label="分享 UUID" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column label="分享类型" width="110">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">
|
||||
{{ enumLabel(SHARE_TYPE, row.share_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">
|
||||
<el-tag :type="enumTag(SHARE_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(SHARE_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" width="150">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">{{ formatDate(row.expire_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="150">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }: { row: ClosetShareItem }">
|
||||
<el-button v-if="row.status === 1" link type="danger" size="small" @click="handleBan(row)">封禁</el-button>
|
||||
<el-button v-if="row.status === 4" link type="success" size="small" @click="handleUnban(row)">解封</el-button>
|
||||
<span v-if="row.status === 2 || row.status === 3" class="no-action">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无分享数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-action {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,601 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import SearchFormCard from '@/components/SearchFormCard.vue'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import {
|
||||
adjustUserQuota,
|
||||
adjustUserVip,
|
||||
banUser,
|
||||
getUserDetail,
|
||||
getUserList,
|
||||
revokeGrant,
|
||||
unbanUser,
|
||||
} from '@/api/user'
|
||||
import type {
|
||||
UserDetailResponse,
|
||||
UserGrantItem,
|
||||
UserListItem,
|
||||
} from '@/types/user'
|
||||
import {
|
||||
BAN_TYPE,
|
||||
GRANT_SOURCE,
|
||||
QUOTA_TYPE,
|
||||
USER_SEX,
|
||||
USER_STATUS,
|
||||
VIP_TYPE,
|
||||
enumLabel,
|
||||
enumOptions,
|
||||
enumTag,
|
||||
} from '@/utils/constants'
|
||||
import { formatDate, formatText } from '@/utils/format'
|
||||
|
||||
/* ---------------- 列表与筛选 ---------------- */
|
||||
const loading = ref(false)
|
||||
const list = ref<UserListItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUserList({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status,
|
||||
})
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.keyword = ''
|
||||
query.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
/* ---------------- 详情抽屉 ---------------- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<UserDetailResponse | null>(null)
|
||||
|
||||
async function openDetail(row: UserListItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getUserDetail(row.user_uuid)
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 封禁 ---------------- */
|
||||
const banVisible = ref(false)
|
||||
const banLoading = ref(false)
|
||||
const banTarget = ref<UserListItem | null>(null)
|
||||
const banFormRef = ref<FormInstance>()
|
||||
|
||||
const banForm = reactive({
|
||||
ban_type: 2,
|
||||
ban_days: undefined as number | undefined,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const banRules: FormRules = {
|
||||
ban_type: [{ required: true, message: '请选择封禁类型', trigger: 'change' }],
|
||||
ban_days: [
|
||||
{
|
||||
validator: (_rule, value: number | undefined, callback) => {
|
||||
if (banForm.ban_type === 1 && (!value || value < 1 || value > 3650)) {
|
||||
callback(new Error('临时封禁需填写 1~3650 天'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
reason: [{ required: true, message: '请填写封禁原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openBan(row: UserListItem) {
|
||||
banTarget.value = row
|
||||
banForm.ban_type = 2
|
||||
banForm.ban_days = 7
|
||||
banForm.reason = ''
|
||||
banVisible.value = true
|
||||
}
|
||||
|
||||
async function submitBan() {
|
||||
const valid = await banFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !banTarget.value) return
|
||||
banLoading.value = true
|
||||
try {
|
||||
await banUser(banTarget.value.user_uuid, {
|
||||
ban_type: banForm.ban_type,
|
||||
ban_days: banForm.ban_type === 1 ? banForm.ban_days : undefined,
|
||||
reason: banForm.reason,
|
||||
})
|
||||
ElMessage.success('封禁成功')
|
||||
banVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
banLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 解封 ---------------- */
|
||||
async function handleUnban(row: UserListItem) {
|
||||
const result = (await ElMessageBox.prompt('解封原因(选填)', '解封用户', {
|
||||
confirmButtonText: '确认解封',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '选填,最多 200 字',
|
||||
inputValidator: (val: string) => (val.length <= 200 ? true : '原因不能超过 200 字'),
|
||||
}).catch(() => null)) as { value: string } | null
|
||||
if (!result) return
|
||||
const reason = result.value
|
||||
if (reason === null) return
|
||||
await unbanUser(row.user_uuid, { reason: reason || undefined })
|
||||
ElMessage.success('解封成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
/* ---------------- 额度调整 ---------------- */
|
||||
const quotaVisible = ref(false)
|
||||
const quotaLoading = ref(false)
|
||||
const quotaTarget = ref<UserListItem | null>(null)
|
||||
const quotaFormRef = ref<FormInstance>()
|
||||
|
||||
const quotaForm = reactive({
|
||||
quota_type: 1,
|
||||
amount: 1,
|
||||
expire_days: 30,
|
||||
})
|
||||
|
||||
const quotaRules: FormRules = {
|
||||
quota_type: [{ required: true, message: '请选择额度类型', trigger: 'change' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入发放数量', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: number, callback) => {
|
||||
if (value < 1 || value > 100000000) callback(new Error('发放数量范围 1~100000000'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
expire_days: [
|
||||
{ required: true, message: '请输入有效期天数', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: number, callback) => {
|
||||
if (value < 1 || value > 3650) callback(new Error('有效期天数范围 1~3650'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function openQuota(row: UserListItem) {
|
||||
quotaTarget.value = row
|
||||
quotaForm.quota_type = 1
|
||||
quotaForm.amount = 1
|
||||
quotaForm.expire_days = 30
|
||||
quotaVisible.value = true
|
||||
}
|
||||
|
||||
async function submitQuota() {
|
||||
const valid = await quotaFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !quotaTarget.value) return
|
||||
quotaLoading.value = true
|
||||
try {
|
||||
await adjustUserQuota(quotaTarget.value.user_uuid, {
|
||||
quota_type: quotaForm.quota_type,
|
||||
amount: quotaForm.amount,
|
||||
expire_days: quotaForm.expire_days,
|
||||
})
|
||||
ElMessage.success('额度发放成功')
|
||||
quotaVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
quotaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 额度凭证回收 ---------------- */
|
||||
async function handleRevokeGrant(row: UserGrantItem) {
|
||||
if (!detail.value) return
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确认回收该凭证(类型:${enumLabel(QUOTA_TYPE, row.quota_type)},数量:${row.amount})吗?`,
|
||||
'回收确认',
|
||||
{ type: 'warning', confirmButtonText: '确认回收', confirmButtonClass: 'el-button--danger' },
|
||||
).catch(() => false)
|
||||
if (!confirmed) return
|
||||
await revokeGrant(detail.value.user_uuid, row.id)
|
||||
ElMessage.success('回收成功')
|
||||
detail.value = await getUserDetail(detail.value.user_uuid)
|
||||
}
|
||||
|
||||
/* ---------------- VIP 调整 ---------------- */
|
||||
const vipVisible = ref(false)
|
||||
const vipLoading = ref(false)
|
||||
const vipTarget = ref<UserListItem | null>(null)
|
||||
const vipFormRef = ref<FormInstance>()
|
||||
|
||||
const vipForm = reactive({
|
||||
vip_type: 2,
|
||||
vip_days: 30,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const vipRules: FormRules = {
|
||||
vip_type: [{ required: true, message: '请选择会员类型', trigger: 'change' }],
|
||||
vip_days: [
|
||||
{ required: true, message: '请输入会员天数', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: number, callback) => {
|
||||
if (value < 0 || value > 3650) callback(new Error('天数范围 0~3650(0 表示取消会员)'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
reason: [{ required: true, message: '请填写调整原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openVip(row: UserListItem) {
|
||||
vipTarget.value = row
|
||||
vipForm.vip_type = 2
|
||||
vipForm.vip_days = 30
|
||||
vipForm.reason = ''
|
||||
vipVisible.value = true
|
||||
}
|
||||
|
||||
async function submitVip() {
|
||||
const valid = await vipFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !vipTarget.value) return
|
||||
vipLoading.value = true
|
||||
try {
|
||||
await adjustUserVip(vipTarget.value.user_uuid, {
|
||||
vip_type: vipForm.vip_type,
|
||||
vip_days: vipForm.vip_days,
|
||||
reason: vipForm.reason,
|
||||
})
|
||||
ElMessage.success('VIP 调整成功')
|
||||
vipVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// 错误提示已由请求层处理
|
||||
} finally {
|
||||
vipLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearchFormCard @search="handleSearch" @reset="handleReset">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="昵称 / 手机号 / OpenID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="query.status" placeholder="用户状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in enumOptions(USER_STATUS)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</SearchFormCard>
|
||||
|
||||
<div class="zt-card">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="用户" min-width="160">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
<div class="cell-user">
|
||||
<el-avatar :size="30" :src="row.avatar || undefined">
|
||||
{{ (row.username || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<span class="cell-name">{{ formatText(row.username) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="性别" width="64">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
{{ enumLabel(USER_SEX, row.sex) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手机号" width="130">
|
||||
<template #default="{ row }: { row: UserListItem }">{{ formatText(row.phone) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
<el-tag :type="enumTag(USER_STATUS, row.status)" size="small">
|
||||
{{ enumLabel(USER_STATUS, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会员" width="100">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
<el-tag :type="enumTag(VIP_TYPE, row.vip_type)" size="small">
|
||||
{{ enumLabel(VIP_TYPE, row.vip_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会员到期" width="150">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
{{ formatDate(row.vip_expire) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="注册时间" width="150">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }: { row: UserListItem }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.status === 1" link type="warning" size="small" @click="openBan(row)">
|
||||
封禁
|
||||
</el-button>
|
||||
<el-button v-else-if="row.status === 2 || row.status === 3" link type="success" size="small" @click="handleUnban(row)">
|
||||
解封
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openQuota(row)">额度</el-button>
|
||||
<el-button link type="primary" size="small" @click="openVip(row)">VIP</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无用户数据" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<PaginationBar v-model:page="page" v-model:page-size="pageSize" :total="total" @change="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- 用户详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="用户详情" size="460px">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-header">
|
||||
<el-avatar :size="56" :src="detail.avatar || undefined">
|
||||
{{ (detail.username || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="detail-name">{{ formatText(detail.username) }}</div>
|
||||
<div class="detail-uuid">{{ detail.user_uuid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ enumLabel(USER_SEX, detail.sex) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ formatText(detail.phone) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="enumTag(USER_STATUS, detail.status)" size="small">
|
||||
{{ enumLabel(USER_STATUS, detail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.ban_reason" label="封禁原因">
|
||||
{{ detail.ban_reason }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.ban_start" label="封禁开始">
|
||||
{{ formatDate(detail.ban_start) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.ban_expire" label="封禁到期">
|
||||
{{ formatDate(detail.ban_expire) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="会员类型">
|
||||
<el-tag :type="enumTag(VIP_TYPE, detail.vip_type)" size="small">
|
||||
{{ enumLabel(VIP_TYPE, detail.vip_type) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="会员到期">{{ formatDate(detail.vip_expire) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录">{{ formatDate(detail.last_login_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatDate(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 class="section-title">额度信息</h4>
|
||||
<el-table :data="detail.quotas" size="small" border>
|
||||
<el-table-column label="类型">
|
||||
<template #default="{ row }">{{ enumLabel(QUOTA_TYPE, row.quota_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总量">
|
||||
<template #default="{ row }">{{ row.total }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用">
|
||||
<template #default="{ row }">{{ row.used }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余">
|
||||
<template #default="{ row }">{{ row.remain }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<h4 class="section-title">额度凭证</h4>
|
||||
<el-table :data="detail.grants" size="small" border>
|
||||
<el-table-column label="类型">
|
||||
<template #default="{ row }">{{ enumLabel(QUOTA_TYPE, row.quota_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源">
|
||||
<template #default="{ row }">{{ enumLabel(GRANT_SOURCE, row.source) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量">
|
||||
<template #default="{ row }">{{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用">
|
||||
<template #default="{ row }">{{ row.used }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期">
|
||||
<template #default="{ row }">
|
||||
{{ row.expire_time === 0 ? '跟随 VIP' : formatDate(row.expire_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联 ID">
|
||||
<template #default="{ row }">{{ formatText(row.ref_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" @click="handleRevokeGrant(row)">回收</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无凭证" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 封禁对话框 -->
|
||||
<el-dialog v-model="banVisible" :title="`封禁用户 · ${banTarget?.username ?? ''}`" width="440px">
|
||||
<el-form ref="banFormRef" :model="banForm" :rules="banRules" label-width="88px">
|
||||
<el-form-item label="封禁类型" prop="ban_type">
|
||||
<el-radio-group v-model="banForm.ban_type">
|
||||
<el-radio v-for="opt in enumOptions(BAN_TYPE)" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="banForm.ban_type === 1" label="封禁天数" prop="ban_days">
|
||||
<el-input-number v-model="banForm.ban_days" :min="1" :max="3650" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封禁原因" prop="reason">
|
||||
<el-input v-model="banForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="请填写封禁原因(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="banVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="banLoading" @click="submitBan">确认封禁</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 额度发放对话框 -->
|
||||
<el-dialog v-model="quotaVisible" :title="`额度发放 · ${quotaTarget?.username ?? ''}`" width="440px">
|
||||
<el-form ref="quotaFormRef" :model="quotaForm" :rules="quotaRules" label-width="88px">
|
||||
<el-form-item label="额度类型" prop="quota_type">
|
||||
<el-select v-model="quotaForm.quota_type" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in enumOptions(QUOTA_TYPE)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="发放数量" prop="amount">
|
||||
<el-input-number v-model="quotaForm.amount" :min="1" :max="100000000" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期天数" prop="expire_days">
|
||||
<el-input-number v-model="quotaForm.expire_days" :min="1" :max="3650" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="quotaVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="quotaLoading" @click="submitQuota">确认发放</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- VIP 调整对话框 -->
|
||||
<el-dialog v-model="vipVisible" :title="`VIP 调整 · ${vipTarget?.username ?? ''}`" width="440px">
|
||||
<el-form ref="vipFormRef" :model="vipForm" :rules="vipRules" label-width="88px">
|
||||
<el-form-item label="会员类型" prop="vip_type">
|
||||
<el-select v-model="vipForm.vip_type" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in enumOptions(VIP_TYPE).filter((o) => o.value !== 1)"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="会员天数" prop="vip_days">
|
||||
<el-input-number v-model="vipForm.vip_days" :min="0" :max="3650" controls-position="right" style="width: 100%" />
|
||||
<div class="form-tip">天数为 0 表示取消会员;其余按当前到期时间顺延</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整原因" prop="reason">
|
||||
<el-input v-model="vipForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="请填写调整原因(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="vipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="vipLoading" @click="submitVip">确认调整</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cell-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2437;
|
||||
}
|
||||
|
||||
.detail-uuid {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 14px;
|
||||
color: #1f2437;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #9aa3c0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user