feat: 完成会员/保质期/系统设置/去水印管理页面开发
This commit is contained in:
@@ -17,14 +17,18 @@ npm run build
|
|||||||
|
|
||||||
## 当前已完成
|
## 当前已完成
|
||||||
|
|
||||||
- 管理员登录(`/api/admin/login`)
|
- 管理员登录与认证(`/api/admin/login`、`/api/admin/profile`)
|
||||||
- 管理员信息(`/api/admin/profile`)
|
- 后台基础布局(顶部导航、侧边栏、移动端适配)
|
||||||
- 数据看板(总览、小程序统计、用户增长)
|
- 数据看板(总览、小程序统计、用户增长图表)
|
||||||
- 小程序管理(列表、新增、编辑、删除)
|
- 小程序管理(列表、新增、编辑、删除)
|
||||||
|
- 用户管理(列表、筛选、详情)
|
||||||
|
- 会员管理页面骨架与兑换码管理交互
|
||||||
|
- 保质期管理页面(总览、列表、增删改、状态流转)
|
||||||
|
- 去水印管理页面(任务总览与列表)
|
||||||
|
- 系统设置页面(资料/密码/系统参数)
|
||||||
|
|
||||||
## 待开发
|
## 待开发
|
||||||
|
|
||||||
- 用户管理
|
- 会员、保质期、去水印、系统设置接口联调
|
||||||
- 会员管理
|
- 会员管理的记录详情与批量操作
|
||||||
- 保质期管理
|
- 系统设置中的操作日志查询
|
||||||
- 系统设置
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import request from '../utils/request'
|
||||||
|
|
||||||
|
export function getExpirySummary() {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/expiry/summary',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpiryItems(params) {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/expiry/items',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExpiryItem(data) {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/expiry/items',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExpiryItem(id, data) {
|
||||||
|
return request({
|
||||||
|
url: `/api/admin/expiry/items/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteExpiryItem(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/admin/expiry/items/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExpiryItemStatus(id, status) {
|
||||||
|
return request({
|
||||||
|
url: `/api/admin/expiry/items/${id}/status`,
|
||||||
|
method: 'post',
|
||||||
|
data: { status }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import request from '../utils/request'
|
||||||
|
|
||||||
|
export function getMembershipOverview() {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/memberships/overview',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMembershipRedeemCodes(params) {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/memberships/redeem-codes',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMembershipRedeemCodes(data) {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/memberships/redeem-codes',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMembershipRedeemCodeStatus(id, data) {
|
||||||
|
return request({
|
||||||
|
url: `/api/admin/memberships/redeem-codes/${id}/status`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import request from '../utils/request'
|
||||||
|
|
||||||
|
export function getAdminSettings() {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/settings',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminProfile(data) {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/settings/profile',
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminPassword(data) {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/settings/password',
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemConfig() {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/settings/system',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSystemConfig(data) {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/settings/system',
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import request from '../utils/request'
|
||||||
|
|
||||||
|
export function getWatermarkOverview() {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/watermark/overview',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWatermarkTasks(params) {
|
||||||
|
return request({
|
||||||
|
url: '/api/admin/watermark/tasks',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryWatermarkTask(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/admin/watermark/tasks/${id}/retry`,
|
||||||
|
method: 'post'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWatermarkTask(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/admin/watermark/tasks/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
+470
-2
@@ -1,8 +1,476 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="expiry-page">
|
||||||
|
<el-row :gutter="16" class="cards">
|
||||||
|
<el-col :xs="12" :sm="12" :md="4">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value">{{ summary.total_items || 0 }}</div>
|
||||||
|
<div class="card-label">物品总数</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="4">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value warning">{{ summary.expiring_soon || 0 }}</div>
|
||||||
|
<div class="card-label">即将过期</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="4">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value danger">{{ summary.expired || 0 }}</div>
|
||||||
|
<div class="card-label">已过期</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="4">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value success">{{ summary.normal || 0 }}</div>
|
||||||
|
<div class="card-label">正常</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="4">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value">{{ summary.used || 0 }}</div>
|
||||||
|
<div class="card-label">已使用</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="4">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value">{{ summary.discarded || 0 }}</div>
|
||||||
|
<div class="card-label">已丢弃</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>保质期管理</span>
|
<div class="header-row">
|
||||||
|
<div class="filters">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
placeholder="搜索物品名称"
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="query.status"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
placeholder="全部状态"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="正常" value="normal" />
|
||||||
|
<el-option label="即将过期" value="expiring" />
|
||||||
|
<el-option label="已过期" value="expired" />
|
||||||
|
<el-option label="已使用" value="used" />
|
||||||
|
<el-option label="已丢弃" value="discarded" />
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="query.category"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
placeholder="全部分类"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="食品" value="food" />
|
||||||
|
<el-option label="药品" value="medicine" />
|
||||||
|
<el-option label="化妆品" value="cosmetic" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openCreateDialog">新增物品</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-empty description="开发中,下一步对接保质期后台接口" />
|
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="name" label="物品名称" min-width="160" />
|
||||||
|
<el-table-column label="分类" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ categoryText(row.category) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="过期日期" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.expiry_date) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="剩余天数" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="daysClass(row.days_left)">{{ row.days_left ?? '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数量" width="80" prop="quantity" />
|
||||||
|
<el-table-column prop="location" label="位置" min-width="120" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!['used', 'discarded'].includes(row.status)"
|
||||||
|
link
|
||||||
|
type="success"
|
||||||
|
@click="handleMarkStatus(row, 'used')"
|
||||||
|
>
|
||||||
|
标记已使用
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!['used', 'discarded'].includes(row.status)"
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
@click="handleMarkStatus(row, 'discarded')"
|
||||||
|
>
|
||||||
|
标记丢弃
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="query.page"
|
||||||
|
v-model:page-size="query.page_size"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
@current-change="loadData"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑物品' : '新增物品'" width="560px">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="物品名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类" prop="category">
|
||||||
|
<el-select v-model="form.category" style="width: 100%">
|
||||||
|
<el-option label="食品" value="food" />
|
||||||
|
<el-option label="药品" value="medicine" />
|
||||||
|
<el-option label="化妆品" value="cosmetic" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="过期日期" prop="expiry_date">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.expiry_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="数量" prop="quantity">
|
||||||
|
<el-input-number v-model="form.quantity" :min="1" :max="999" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="存放位置">
|
||||||
|
<el-input v-model="form.location" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
createExpiryItem,
|
||||||
|
deleteExpiryItem,
|
||||||
|
getExpiryItems,
|
||||||
|
getExpirySummary,
|
||||||
|
updateExpiryItem,
|
||||||
|
updateExpiryItemStatus
|
||||||
|
} from '../../api/expiry'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const formRef = ref(null)
|
||||||
|
|
||||||
|
const summary = reactive({
|
||||||
|
total_items: 0,
|
||||||
|
expiring_soon: 0,
|
||||||
|
expired: 0,
|
||||||
|
normal: 0,
|
||||||
|
used: 0,
|
||||||
|
discarded: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
keyword: '',
|
||||||
|
status: undefined,
|
||||||
|
category: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
id: undefined,
|
||||||
|
name: '',
|
||||||
|
category: 'food',
|
||||||
|
expiry_date: '',
|
||||||
|
quantity: 1,
|
||||||
|
location: '',
|
||||||
|
remark: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: '请输入物品名称', trigger: 'blur' }],
|
||||||
|
category: [{ required: true, message: '请选择分类', trigger: 'change' }],
|
||||||
|
expiry_date: [{ required: true, message: '请选择过期日期', trigger: 'change' }],
|
||||||
|
quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryText = (category) => {
|
||||||
|
if (category === 'food') return '食品'
|
||||||
|
if (category === 'medicine') return '药品'
|
||||||
|
if (category === 'cosmetic') return '化妆品'
|
||||||
|
if (category === 'other') return '其他'
|
||||||
|
return category || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = (status) => {
|
||||||
|
if (status === 'normal') return '正常'
|
||||||
|
if (status === 'expiring') return '即将过期'
|
||||||
|
if (status === 'expired') return '已过期'
|
||||||
|
if (status === 'used') return '已使用'
|
||||||
|
if (status === 'discarded') return '已丢弃'
|
||||||
|
return status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTagType = (status) => {
|
||||||
|
if (status === 'normal') return 'success'
|
||||||
|
if (status === 'expiring') return 'warning'
|
||||||
|
if (status === 'expired') return 'danger'
|
||||||
|
if (status === 'used') return 'info'
|
||||||
|
if (status === 'discarded') return ''
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysClass = (daysLeft) => {
|
||||||
|
if (daysLeft == null) return ''
|
||||||
|
if (daysLeft < 0) return 'danger'
|
||||||
|
if (daysLeft <= 7) return 'warning'
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
const res = await getExpirySummary()
|
||||||
|
const payload = res.data || {}
|
||||||
|
summary.total_items = payload.total_items || 0
|
||||||
|
summary.expiring_soon = payload.expiring_soon || 0
|
||||||
|
summary.expired = payload.expired || 0
|
||||||
|
summary.normal = payload.normal || 0
|
||||||
|
summary.used = payload.used || 0
|
||||||
|
summary.discarded = payload.discarded || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getExpiryItems(query)
|
||||||
|
const payload = res.data || {}
|
||||||
|
list.value = payload.list || payload.items || []
|
||||||
|
total.value = payload.total || 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
query.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
query.keyword = ''
|
||||||
|
query.status = undefined
|
||||||
|
query.category = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = () => {
|
||||||
|
query.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
form.id = undefined
|
||||||
|
form.name = ''
|
||||||
|
form.category = 'food'
|
||||||
|
form.expiry_date = ''
|
||||||
|
form.quantity = 1
|
||||||
|
form.location = ''
|
||||||
|
form.remark = ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (row) => {
|
||||||
|
isEdit.value = true
|
||||||
|
form.id = row.id
|
||||||
|
form.name = row.name || ''
|
||||||
|
form.category = row.category || 'food'
|
||||||
|
form.expiry_date = row.expiry_date ? dayjs(row.expiry_date).format('YYYY-MM-DD') : ''
|
||||||
|
form.quantity = row.quantity || 1
|
||||||
|
form.location = row.location || ''
|
||||||
|
form.remark = row.remark || ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch (error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
category: form.category,
|
||||||
|
expiry_date: form.expiry_date,
|
||||||
|
quantity: form.quantity,
|
||||||
|
location: form.location,
|
||||||
|
remark: form.remark
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value && form.id) {
|
||||||
|
await updateExpiryItem(form.id, payload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createExpiryItem(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = false
|
||||||
|
await Promise.all([loadSummary(), loadData()])
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkStatus = async (row, status) => {
|
||||||
|
const actionText = status === 'used' ? '标记为已使用' : '标记为已丢弃'
|
||||||
|
await ElMessageBox.confirm(`确认${actionText}吗?`, '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
await updateExpiryItemStatus(row.id, status)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
await Promise.all([loadSummary(), loadData()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
await ElMessageBox.confirm('删除后不可恢复,确认删除该物品?', '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
await deleteExpiryItem(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await Promise.all([loadSummary(), loadData()])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadSummary(), loadData()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cards {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value.warning {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value.danger {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value.success {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,385 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="memberships-page">
|
||||||
|
<el-row :gutter="16" class="cards">
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value">{{ overview.total_members || 0 }}</div>
|
||||||
|
<div class="card-label">会员总数</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value">{{ overview.active_members || 0 }}</div>
|
||||||
|
<div class="card-label">有效会员</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value">{{ overview.expiring_soon || 0 }}</div>
|
||||||
|
<div class="card-label">7天内到期</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value">{{ overview.today_redeemed || 0 }}</div>
|
||||||
|
<div class="card-label">今日兑换</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>会员管理</span>
|
<div class="header-row">
|
||||||
|
<div class="filters">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
placeholder="搜索兑换码"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="query.mini_program_id"
|
||||||
|
placeholder="全部小程序"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in miniProgramOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="query.status"
|
||||||
|
placeholder="全部状态"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="可用" value="active" />
|
||||||
|
<el-option label="已停用" value="disabled" />
|
||||||
|
<el-option label="已过期" value="expired" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openCreateDialog">生成兑换码</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-empty description="开发中,下一步对接会员管理接口" />
|
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="code" label="兑换码" min-width="180" />
|
||||||
|
<el-table-column label="小程序" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.mini_program_name || row.mini_program?.name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="套餐类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.package_type || row.plan_name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时长(天)" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.duration_days || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="使用次数" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.used_count || 0 }}/{{ row.max_uses || 1 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)">
|
||||||
|
{{ statusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="过期时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.expires_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'active'"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="handleDisable(row)"
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="query.page"
|
||||||
|
v-model:page-size="query.page_size"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
@current-change="loadData"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="createDialogVisible" title="生成兑换码" width="520px">
|
||||||
|
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
|
||||||
|
<el-form-item label="所属小程序" prop="mini_program_id">
|
||||||
|
<el-select v-model="createForm.mini_program_id" placeholder="请选择小程序" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="item in miniProgramOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="套餐类型" prop="package_type">
|
||||||
|
<el-select v-model="createForm.package_type" style="width: 100%">
|
||||||
|
<el-option label="月卡" value="month" />
|
||||||
|
<el-option label="季卡" value="quarter" />
|
||||||
|
<el-option label="年卡" value="year" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时长(天)" prop="duration_days">
|
||||||
|
<el-input-number v-model="createForm.duration_days" :min="1" :max="3650" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="生成数量" prop="quantity">
|
||||||
|
<el-input-number v-model="createForm.quantity" :min="1" :max="200" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="每码可用次数" prop="max_uses">
|
||||||
|
<el-input-number v-model="createForm.max_uses" :min="1" :max="100" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="到期时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="createForm.expires_at"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="可选,不填则长期有效"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="creating" @click="handleCreate">生成</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { getMiniPrograms } from '../../api/miniProgram'
|
||||||
|
import {
|
||||||
|
createMembershipRedeemCodes,
|
||||||
|
getMembershipOverview,
|
||||||
|
getMembershipRedeemCodes,
|
||||||
|
updateMembershipRedeemCodeStatus
|
||||||
|
} from '../../api/membership'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const miniProgramOptions = ref([])
|
||||||
|
const createDialogVisible = ref(false)
|
||||||
|
const createFormRef = ref(null)
|
||||||
|
|
||||||
|
const overview = reactive({
|
||||||
|
total_members: 0,
|
||||||
|
active_members: 0,
|
||||||
|
expiring_soon: 0,
|
||||||
|
today_redeemed: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
mini_program_id: undefined,
|
||||||
|
status: undefined,
|
||||||
|
keyword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const createForm = reactive({
|
||||||
|
mini_program_id: undefined,
|
||||||
|
package_type: 'month',
|
||||||
|
duration_days: 30,
|
||||||
|
quantity: 10,
|
||||||
|
max_uses: 1,
|
||||||
|
expires_at: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const createRules = {
|
||||||
|
mini_program_id: [{ required: true, message: '请选择小程序', trigger: 'change' }],
|
||||||
|
package_type: [{ required: true, message: '请选择套餐类型', trigger: 'change' }],
|
||||||
|
duration_days: [{ required: true, message: '请输入时长', trigger: 'blur' }],
|
||||||
|
quantity: [{ required: true, message: '请输入生成数量', trigger: 'blur' }],
|
||||||
|
max_uses: [{ required: true, message: '请输入可用次数', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = (status) => {
|
||||||
|
if (status === 'active') return '可用'
|
||||||
|
if (status === 'disabled') return '已停用'
|
||||||
|
if (status === 'expired') return '已过期'
|
||||||
|
return status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTagType = (status) => {
|
||||||
|
if (status === 'active') return 'success'
|
||||||
|
if (status === 'disabled') return 'info'
|
||||||
|
if (status === 'expired') return 'danger'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMiniPrograms = async () => {
|
||||||
|
const res = await getMiniPrograms({ page: 1, page_size: 200 })
|
||||||
|
const payload = res.data || {}
|
||||||
|
miniProgramOptions.value = payload.list || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOverview = async () => {
|
||||||
|
const res = await getMembershipOverview()
|
||||||
|
const payload = res.data || {}
|
||||||
|
overview.total_members = payload.total_members || 0
|
||||||
|
overview.active_members = payload.active_members || 0
|
||||||
|
overview.expiring_soon = payload.expiring_soon || 0
|
||||||
|
overview.today_redeemed = payload.today_redeemed || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getMembershipRedeemCodes(query)
|
||||||
|
const payload = res.data || {}
|
||||||
|
list.value = payload.list || payload.items || []
|
||||||
|
total.value = payload.total || 0
|
||||||
|
query.page = payload.page || query.page
|
||||||
|
query.page_size = payload.page_size || query.page_size
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
query.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
query.keyword = ''
|
||||||
|
query.mini_program_id = undefined
|
||||||
|
query.status = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = () => {
|
||||||
|
query.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
createDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!createFormRef.value) return
|
||||||
|
try {
|
||||||
|
await createFormRef.value.validate()
|
||||||
|
} catch (error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const res = await createMembershipRedeemCodes(createForm)
|
||||||
|
const codes = res?.data?.codes || []
|
||||||
|
ElMessage.success(`生成成功,共 ${createForm.quantity} 个`)
|
||||||
|
if (codes.length > 0) {
|
||||||
|
await ElMessageBox.alert(codes.join('\n'), '新生成兑换码', {
|
||||||
|
confirmButtonText: '确定'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createDialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisable = async (row) => {
|
||||||
|
await ElMessageBox.confirm('停用后该兑换码将无法再使用,是否继续?', '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
await updateMembershipRedeemCodeStatus(row.id, { status: 'disabled' })
|
||||||
|
ElMessage.success('已停用')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadMiniPrograms(), loadOverview(), loadData()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cards {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,235 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<span>系统设置</span>
|
<span>系统设置</span>
|
||||||
</template>
|
</template>
|
||||||
<el-empty description="开发中,下一步补充密码修改和日志查询" />
|
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="个人资料" name="profile">
|
||||||
|
<el-form
|
||||||
|
ref="profileFormRef"
|
||||||
|
:model="profileForm"
|
||||||
|
:rules="profileRules"
|
||||||
|
label-width="120px"
|
||||||
|
style="max-width: 680px"
|
||||||
|
>
|
||||||
|
<el-form-item label="用户名" prop="username">
|
||||||
|
<el-input v-model="profileForm.username" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="显示名称" prop="display_name">
|
||||||
|
<el-input v-model="profileForm.display_name" placeholder="请输入显示名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input v-model="profileForm.email" placeholder="请输入邮箱" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input v-model="profileForm.phone" placeholder="请输入手机号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时区">
|
||||||
|
<el-select v-model="profileForm.timezone" style="width: 100%">
|
||||||
|
<el-option label="Asia/Shanghai" value="Asia/Shanghai" />
|
||||||
|
<el-option label="UTC" value="UTC" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="profileSaving" @click="handleSaveProfile">保存资料</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="安全设置" name="security">
|
||||||
|
<el-form
|
||||||
|
ref="passwordFormRef"
|
||||||
|
:model="passwordForm"
|
||||||
|
:rules="passwordRules"
|
||||||
|
label-width="120px"
|
||||||
|
style="max-width: 560px"
|
||||||
|
>
|
||||||
|
<el-form-item label="当前密码" prop="old_password">
|
||||||
|
<el-input v-model="passwordForm.old_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码" prop="new_password">
|
||||||
|
<el-input v-model="passwordForm.new_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认新密码" prop="confirm_password">
|
||||||
|
<el-input v-model="passwordForm.confirm_password" type="password" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="passwordSaving" @click="handleChangePassword">
|
||||||
|
更新密码
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="系统参数" name="system">
|
||||||
|
<el-form label-width="180px" style="max-width: 720px">
|
||||||
|
<el-form-item label="后台名称">
|
||||||
|
<el-input v-model="systemForm.site_name" placeholder="系统名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="允许注册">
|
||||||
|
<el-switch v-model="systemForm.allow_register" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="登录失败锁定阈值">
|
||||||
|
<el-input-number v-model="systemForm.login_fail_limit" :min="3" :max="20" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认分页大小">
|
||||||
|
<el-input-number v-model="systemForm.default_page_size" :min="10" :max="200" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作日志保留天数">
|
||||||
|
<el-input-number v-model="systemForm.audit_log_retention_days" :min="7" :max="3650" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="systemSaving" @click="handleSaveSystem">保存参数</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
getAdminSettings,
|
||||||
|
getSystemConfig,
|
||||||
|
updateAdminPassword,
|
||||||
|
updateAdminProfile,
|
||||||
|
updateSystemConfig
|
||||||
|
} from '../../api/settings'
|
||||||
|
|
||||||
|
const activeTab = ref('profile')
|
||||||
|
const profileFormRef = ref(null)
|
||||||
|
const passwordFormRef = ref(null)
|
||||||
|
|
||||||
|
const profileSaving = ref(false)
|
||||||
|
const passwordSaving = ref(false)
|
||||||
|
const systemSaving = ref(false)
|
||||||
|
|
||||||
|
const profileForm = reactive({
|
||||||
|
username: '',
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
timezone: 'Asia/Shanghai'
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordForm = reactive({
|
||||||
|
old_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const systemForm = reactive({
|
||||||
|
site_name: '多小程序管理后台',
|
||||||
|
allow_register: false,
|
||||||
|
login_fail_limit: 5,
|
||||||
|
default_page_size: 20,
|
||||||
|
audit_log_retention_days: 180
|
||||||
|
})
|
||||||
|
|
||||||
|
const profileRules = {
|
||||||
|
display_name: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
|
||||||
|
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPasswordValidator = (rule, value, callback) => {
|
||||||
|
if (!value) {
|
||||||
|
callback(new Error('请再次输入新密码'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (value !== passwordForm.new_password) {
|
||||||
|
callback(new Error('两次输入密码不一致'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordRules = {
|
||||||
|
old_password: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
|
||||||
|
new_password: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
confirm_password: [{ validator: confirmPasswordValidator, trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
const res = await getAdminSettings()
|
||||||
|
const payload = res.data || {}
|
||||||
|
profileForm.username = payload.username || ''
|
||||||
|
profileForm.display_name = payload.display_name || payload.nickname || payload.username || ''
|
||||||
|
profileForm.email = payload.email || ''
|
||||||
|
profileForm.phone = payload.phone || ''
|
||||||
|
profileForm.timezone = payload.timezone || 'Asia/Shanghai'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSystemConfig = async () => {
|
||||||
|
const res = await getSystemConfig()
|
||||||
|
const payload = res.data || {}
|
||||||
|
systemForm.site_name = payload.site_name || systemForm.site_name
|
||||||
|
systemForm.allow_register = payload.allow_register ?? systemForm.allow_register
|
||||||
|
systemForm.login_fail_limit = payload.login_fail_limit || systemForm.login_fail_limit
|
||||||
|
systemForm.default_page_size = payload.default_page_size || systemForm.default_page_size
|
||||||
|
systemForm.audit_log_retention_days =
|
||||||
|
payload.audit_log_retention_days || systemForm.audit_log_retention_days
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!profileFormRef.value) return
|
||||||
|
try {
|
||||||
|
await profileFormRef.value.validate()
|
||||||
|
} catch (error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
profileSaving.value = true
|
||||||
|
try {
|
||||||
|
await updateAdminProfile({
|
||||||
|
display_name: profileForm.display_name,
|
||||||
|
email: profileForm.email,
|
||||||
|
phone: profileForm.phone,
|
||||||
|
timezone: profileForm.timezone
|
||||||
|
})
|
||||||
|
ElMessage.success('资料保存成功')
|
||||||
|
} finally {
|
||||||
|
profileSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
if (!passwordFormRef.value) return
|
||||||
|
try {
|
||||||
|
await passwordFormRef.value.validate()
|
||||||
|
} catch (error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordSaving.value = true
|
||||||
|
try {
|
||||||
|
await updateAdminPassword({
|
||||||
|
old_password: passwordForm.old_password,
|
||||||
|
new_password: passwordForm.new_password
|
||||||
|
})
|
||||||
|
passwordForm.old_password = ''
|
||||||
|
passwordForm.new_password = ''
|
||||||
|
passwordForm.confirm_password = ''
|
||||||
|
ElMessage.success('密码更新成功,请使用新密码重新登录')
|
||||||
|
} finally {
|
||||||
|
passwordSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveSystem = async () => {
|
||||||
|
systemSaving.value = true
|
||||||
|
try {
|
||||||
|
await updateSystemConfig(systemForm)
|
||||||
|
ElMessage.success('系统参数已保存')
|
||||||
|
} finally {
|
||||||
|
systemSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadProfile(), loadSystemConfig()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,326 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="watermark-page">
|
||||||
|
<el-row :gutter="16" class="cards">
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value">{{ overview.total_tasks || 0 }}</div>
|
||||||
|
<div class="card-label">总任务数</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value success">{{ overview.success_tasks || 0 }}</div>
|
||||||
|
<div class="card-label">成功任务</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value warning">{{ overview.processing_tasks || 0 }}</div>
|
||||||
|
<div class="card-label">处理中</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="card-value danger">{{ overview.failed_tasks || 0 }}</div>
|
||||||
|
<div class="card-label">失败任务</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>去水印管理</span>
|
<div class="header-row">
|
||||||
|
<div class="filters">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
placeholder="搜索视频链接/用户ID"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="query.mini_program_id"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
placeholder="全部小程序"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in miniProgramOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="query.status"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
placeholder="全部状态"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="成功" value="success" />
|
||||||
|
<el-option label="处理中" value="processing" />
|
||||||
|
<el-option label="失败" value="failed" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button @click="refreshAll">刷新</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-empty description="开发中,下一步对接去水印任务管理接口" />
|
|
||||||
|
<el-table v-loading="loading" :data="list" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column label="小程序" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.mini_program_name || row.mini_program?.name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="平台" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.platform || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="原始链接" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link :href="row.source_url" target="_blank" type="primary" :underline="false">
|
||||||
|
{{ row.source_url || '-' }}
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="解析结果" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="row.cleaned_url || row.result_url"
|
||||||
|
:href="row.cleaned_url || row.result_url"
|
||||||
|
target="_blank"
|
||||||
|
type="success"
|
||||||
|
:underline="false"
|
||||||
|
>
|
||||||
|
查看无水印链接
|
||||||
|
</el-link>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="耗时(ms)" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.duration_ms || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="失败原因" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.error_message || row.fail_reason || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'failed'"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="handleRetry(row)"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="query.page"
|
||||||
|
v-model:page-size="query.page_size"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
@current-change="loadData"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { getMiniPrograms } from '../../api/miniProgram'
|
||||||
|
import {
|
||||||
|
deleteWatermarkTask,
|
||||||
|
getWatermarkOverview,
|
||||||
|
getWatermarkTasks,
|
||||||
|
retryWatermarkTask
|
||||||
|
} from '../../api/watermark'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const miniProgramOptions = ref([])
|
||||||
|
|
||||||
|
const overview = reactive({
|
||||||
|
total_tasks: 0,
|
||||||
|
success_tasks: 0,
|
||||||
|
processing_tasks: 0,
|
||||||
|
failed_tasks: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
mini_program_id: undefined,
|
||||||
|
status: undefined,
|
||||||
|
keyword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = (status) => {
|
||||||
|
if (status === 'success') return '成功'
|
||||||
|
if (status === 'processing') return '处理中'
|
||||||
|
if (status === 'failed') return '失败'
|
||||||
|
return status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTagType = (status) => {
|
||||||
|
if (status === 'success') return 'success'
|
||||||
|
if (status === 'processing') return 'warning'
|
||||||
|
if (status === 'failed') return 'danger'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMiniPrograms = async () => {
|
||||||
|
const res = await getMiniPrograms({ page: 1, page_size: 200 })
|
||||||
|
const payload = res.data || {}
|
||||||
|
miniProgramOptions.value = payload.list || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOverview = async () => {
|
||||||
|
const res = await getWatermarkOverview()
|
||||||
|
const payload = res.data || {}
|
||||||
|
overview.total_tasks = payload.total_tasks || 0
|
||||||
|
overview.success_tasks = payload.success_tasks || 0
|
||||||
|
overview.processing_tasks = payload.processing_tasks || 0
|
||||||
|
overview.failed_tasks = payload.failed_tasks || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getWatermarkTasks(query)
|
||||||
|
const payload = res.data || {}
|
||||||
|
list.value = payload.list || payload.items || []
|
||||||
|
total.value = payload.total || 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAll = async () => {
|
||||||
|
await Promise.all([loadOverview(), loadData()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
query.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
query.keyword = ''
|
||||||
|
query.status = undefined
|
||||||
|
query.mini_program_id = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = () => {
|
||||||
|
query.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetry = async (row) => {
|
||||||
|
await retryWatermarkTask(row.id)
|
||||||
|
ElMessage.success('已提交重试')
|
||||||
|
refreshAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
await ElMessageBox.confirm('确认删除该任务记录吗?', '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
await deleteWatermarkTask(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
refreshAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadMiniPrograms(), loadOverview(), loadData()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cards {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value.success {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value.warning {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value.danger {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user