first commit

This commit is contained in:
2026-09-11 01:46:36 +08:00
commit df4332a2a3
58 changed files with 9771 additions and 0 deletions
+279
View File
@@ -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>
+711
View File
@@ -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>
+422
View File
@@ -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>
+191
View File
@@ -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>
+220
View File
@@ -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>
+192
View File
@@ -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>
+231
View File
@@ -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>
+241
View File
@@ -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>
+349
View File
@@ -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>
+142
View File
@@ -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>
+601
View File
@@ -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>