first commit

This commit is contained in:
2026-04-14 15:06:26 +08:00
commit 9ee0c6c597
582 changed files with 61051 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
用户编号
</view>
<wd-input
v-model="formData.userId"
placeholder="请输入用户编号"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
应用名
</view>
<wd-input
v-model="formData.applicationName"
placeholder="请输入应用名"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
访问时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getNavbarHeight } from '@/utils'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
userId: undefined as number | undefined,
applicationName: undefined as string | undefined,
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.userId) {
conditions.push(`用户编号:${formData.userId}`)
}
if (formData.applicationName) {
conditions.push(`应用名:${formData.applicationName}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索日志'
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 访问时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 访问时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
const dateRange = formatDateRange(formData.createTime)
emit('search', {
userId: formData.userId,
applicationName: formData.applicationName,
beginTime: dateRange?.[0],
endTime: dateRange?.[1],
})
}
/** 重置 */
function handleReset() {
formData.userId = undefined
formData.applicationName = undefined
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,133 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="访问日志详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="日志编号" :value="formData?.id" />
<wd-cell title="链路追踪" :value="formData?.traceId || '-'" />
<wd-cell title="应用名" :value="formData?.applicationName" />
<wd-cell title="用户编号" :value="formData?.userId ?? '-'" />
<wd-cell title="用户类型" :value="getDictLabel(DICT_TYPE.USER_TYPE, formData?.userType) || '-'" />
<wd-cell title="用户 IP" :value="formData?.userIp" />
<wd-cell title="用户 UA" :value="formData?.userAgent" />
<wd-cell title="请求信息" :value="getRequestInfo()" />
<wd-cell title="请求参数" is-link @click="handleCopyText(formData?.requestParams, '请求参数')">
<view class="max-w-400rpx truncate text-right">
{{ formData?.requestParams || '-' }}
</view>
</wd-cell>
<wd-cell title="请求结果" is-link @click="handleCopyText(formData?.responseBody, '请求结果')">
<view class="max-w-400rpx truncate text-right">
{{ formData?.responseBody || '-' }}
</view>
</wd-cell>
<wd-cell title="请求时间" :value="getRequestTimeRange()" />
<wd-cell title="请求耗时" :value="`${formData.duration} ms`" />
<wd-cell title="操作结果">
<template v-if="formData?.resultCode === 0">
<wd-tag type="success" plain>
正常
</wd-tag>
</template>
<template v-else-if="formData?.resultCode">
<text>失败 | {{ formData.resultCode }} | {{ formData.resultMsg }}</text>
</template>
<template v-else>
<text>-</text>
</template>
</wd-cell>
<wd-cell title="操作模块" :value="formData?.operateModule || '-'" />
<wd-cell title="操作名" :value="formData?.operateName || '-'" />
<wd-cell title="操作类型" :value="getDictLabel(DICT_TYPE.INFRA_OPERATE_TYPE, formData?.operateType) || '-'" />
</wd-cell-group>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ApiAccessLog } from '@/api/infra/api-access-log'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getApiAccessLog } from '@/api/infra/api-access-log'
import { getDictLabel } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const formData = ref<ApiAccessLog>() // 详情数据
const toast = useToast()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/api-access-log/index')
}
/** 复制文本并提示 */
function handleCopyText(text?: string, title?: string) {
if (!text || text === '-') {
return
}
uni.setClipboardData({
data: text,
success: () => {
uni.hideToast()
toast.success(`${title || '内容'}已复制`)
},
})
}
/** 加载详情 */
async function getDetail() {
if (!props.id) {
return
}
toast.loading('加载中...')
try {
formData.value = await getApiAccessLog(props.id)
} finally {
toast.close()
}
}
/** 获取请求信息 */
function getRequestInfo() {
if (formData.value?.requestMethod && formData.value?.requestUrl) {
return `${formData.value.requestMethod} ${formData.value.requestUrl}`
}
return '-'
}
/** 获取请求时间范围 */
function getRequestTimeRange() {
if (formData.value?.beginTime && formData.value?.endTime) {
return `${formatDateTime(formData.value.beginTime)} ~ ${formatDateTime(formData.value.endTime)}`
}
return '-'
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,158 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="API 访问日志"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 日志列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between gap-16rpx">
<view class="min-w-0 flex-1 truncate text-28rpx text-[#333] font-semibold">
{{ item.requestMethod }} {{ item.requestUrl }}
</view>
<view class="flex-shrink-0">
<wd-tag v-if="item.resultCode === 0" type="success" plain>
成功
</wd-tag>
<wd-tag v-else type="danger" plain>
失败
</wd-tag>
</view>
</view>
<view class="mb-12rpx flex items-center text-26rpx text-[#666]">
<text class="mr-8rpx text-[#999]">应用名</text>
<text>{{ item.applicationName }}</text>
</view>
<view class="mb-12rpx flex items-center text-26rpx text-[#666]">
<text class="mr-8rpx text-[#999]">用户编号</text>
<text>{{ item.userId }}</text>
</view>
<view class="mb-12rpx flex items-center text-26rpx text-[#666]">
<text class="mr-8rpx text-[#999]">执行时长</text>
<text>{{ item.duration }} ms</text>
</view>
<view v-if="item.operateName" class="mb-12rpx flex items-center text-26rpx text-[#666]">
<text class="mr-8rpx text-[#999]">操作名</text>
<text class="line-clamp-1">{{ item.operateName }}</text>
</view>
<view class="flex items-center text-24rpx text-[#999]">
<text>{{ formatDateTime(item.beginTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无日志数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ApiAccessLog } from '@/api/infra/api-access-log'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getApiAccessLogPage } from '@/api/infra/api-access-log'
import { navigateBackPlus } from '@/utils'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const total = ref(0)
const list = ref<ApiAccessLog[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询日志列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getApiAccessLogPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 查看详情 */
function handleDetail(item: ApiAccessLog) {
uni.navigateTo({
url: `/pages-infra/api-access-log/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,172 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<wd-popup
v-model="visible"
position="top"
@close="visible = false"
>
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
用户编号
</view>
<wd-input
v-model="formData.userId"
placeholder="请输入用户编号"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
应用名
</view>
<wd-input
v-model="formData.applicationName"
placeholder="请输入应用名"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
处理状态
</view>
<wd-radio-group v-model="formData.processStatus" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio :value="0">
未处理
</wd-radio>
<wd-radio :value="1">
已处理
</wd-radio>
<wd-radio :value="2">
已忽略
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
异常时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleExceptionTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.exceptionTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleExceptionTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.exceptionTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleExceptionTime[0]" v-model="tempExceptionTime[0]" type="date" />
<view v-if="visibleExceptionTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleExceptionTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleExceptionTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleExceptionTime[1]" v-model="tempExceptionTime[1]" type="date" />
<view v-if="visibleExceptionTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleExceptionTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleExceptionTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getNavbarHeight } from '@/utils'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
userId: undefined as number | undefined,
applicationName: undefined as string | undefined,
processStatus: -1, // -1 表示全部
exceptionTime: [undefined, undefined] as [number | undefined, number | undefined],
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.userId) {
conditions.push(`用户编号:${formData.userId}`)
}
if (formData.applicationName) {
conditions.push(`应用名:${formData.applicationName}`)
}
if (formData.processStatus !== -1) {
const statusMap: Record<number, string> = { 0: '未处理', 1: '已处理', 2: '已忽略' }
conditions.push(`状态:${statusMap[formData.processStatus]}`)
}
if (formData.exceptionTime?.[0] && formData.exceptionTime?.[1]) {
conditions.push(`时间:${formatDate(formData.exceptionTime[0])}~${formatDate(formData.exceptionTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索日志'
})
// 时间范围选择器状态
const visibleExceptionTime = ref<[boolean, boolean]>([false, false])
const tempExceptionTime = ref<[number, number]>([Date.now(), Date.now()])
/** 异常时间[0]确认 */
function handleExceptionTime0Confirm() {
formData.exceptionTime = [tempExceptionTime.value[0], formData.exceptionTime?.[1]]
visibleExceptionTime.value[0] = false
}
/** 异常时间[1]确认 */
function handleExceptionTime1Confirm() {
formData.exceptionTime = [formData.exceptionTime?.[0], tempExceptionTime.value[1]]
visibleExceptionTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
processStatus: formData.processStatus === -1 ? undefined : formData.processStatus,
exceptionTime: formatDateRange(formData.exceptionTime),
})
}
/** 重置 */
function handleReset() {
formData.userId = undefined
formData.applicationName = undefined
formData.processStatus = -1
formData.exceptionTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,160 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="错误日志详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="日志编号" :value="formData?.id" />
<wd-cell title="链路追踪" :value="formData?.traceId || '-'" />
<wd-cell title="应用名" :value="formData?.applicationName" />
<wd-cell title="用户编号" :value="formData?.userId ?? '-'" />
<wd-cell title="用户类型" :value="getDictLabel(DICT_TYPE.USER_TYPE, formData?.userType) || '-'" />
<wd-cell title="用户 IP" :value="formData?.userIp" />
<wd-cell title="用户 UA" :value="formData?.userAgent" />
<wd-cell title="请求信息" :value="getRequestInfo()" />
<wd-cell title="请求参数" is-link @click="handleCopyText(formData?.requestParams, '请求参数')">
<view class="max-w-400rpx truncate text-right">
{{ formData?.requestParams || '-' }}
</view>
</wd-cell>
<wd-cell title="异常时间" :value="formatDateTime(formData?.exceptionTime) || '-'" />
<wd-cell title="异常名" :value="formData?.exceptionName || '-'" />
<wd-cell title="处理状态">
<dict-tag :type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS" :value="formData?.processStatus" />
</wd-cell>
<wd-cell v-if="formData?.processUserId" title="处理人" :value="formData.processUserId" />
<wd-cell v-if="formData?.processTime" title="处理时间" :value="formatDateTime(formData.processTime) || '-'" />
</wd-cell-group>
<!-- 异常堆栈 -->
<view v-if="formData?.exceptionStackTrace" class="mt-24rpx">
<view class="mb-16rpx text-28rpx text-[#333] font-semibold">
异常堆栈
</view>
<view class="rounded-12rpx bg-white p-24rpx shadow-sm">
<scroll-view scroll-y class="max-h-600rpx">
<text class="whitespace-pre-wrap break-all text-24rpx text-[#666] leading-relaxed">{{ formData.exceptionStackTrace }}</text>
</scroll-view>
</view>
</view>
</view>
<!-- 底部操作按钮 -->
<view v-if="formData?.processStatus === InfraApiErrorLogProcessStatusEnum.INIT" class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button class="flex-1" type="success" :loading="processing" @click="handleProcess(InfraApiErrorLogProcessStatusEnum.DONE)">
已处理
</wd-button>
<wd-button class="flex-1" type="warning" :loading="processing" @click="handleProcess(InfraApiErrorLogProcessStatusEnum.IGNORE)">
已忽略
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ApiErrorLog } from '@/api/infra/api-error-log'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getApiErrorLog, updateApiErrorLogStatus } from '@/api/infra/api-error-log'
import { getDictLabel } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE, InfraApiErrorLogProcessStatusEnum } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formData = ref<ApiErrorLog>() // 详情数据
const processing = ref(false) // 处理中
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/api-error-log/index')
}
/** 复制文本并提示 */
function handleCopyText(text?: string, title?: string) {
if (!text || text === '-') {
return
}
uni.setClipboardData({
data: text,
success: () => {
uni.hideToast()
toast.success(`${title || '内容'}已复制`)
},
})
}
/** 加载详情 */
async function getDetail() {
if (!props.id) {
return
}
toast.loading('加载中...')
try {
formData.value = await getApiErrorLog(props.id)
} finally {
toast.close()
}
}
/** 获取请求信息 */
function getRequestInfo() {
if (formData.value?.requestMethod && formData.value?.requestUrl) {
return `${formData.value.requestMethod} ${formData.value.requestUrl}`
}
return '-'
}
/** 处理日志 */
function handleProcess(processStatus: number) {
if (!props.id) {
return
}
const statusText = processStatus === InfraApiErrorLogProcessStatusEnum.DONE ? '已处理' : '已忽略'
uni.showModal({
title: '提示',
content: `确定标记为${statusText}吗?`,
success: async (res) => {
if (!res.confirm) {
return
}
processing.value = true
try {
await updateApiErrorLogStatus(props.id, processStatus)
toast.success('操作成功')
// 刷新详情
await getDetail()
} finally {
processing.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,149 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="API 错误日志"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 日志列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="line-clamp-1 mr-16rpx flex-1 text-28rpx text-[#333] font-semibold">
{{ item.exceptionName }}
</view>
<!-- DONE @芽艺字典 -->
<dict-tag :type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS" :value="item.processStatus" />
</view>
<view class="mb-12rpx flex text-26rpx text-[#666]">
<text class="mr-8rpx flex-shrink-0 text-[#999]">请求</text>
<text class="line-clamp-2 break-all">{{ item.requestMethod }} {{ item.requestUrl }}</text>
</view>
<view class="mb-12rpx flex items-center text-26rpx text-[#666]">
<text class="mr-8rpx text-[#999]">应用名</text>
<text>{{ item.applicationName }}</text>
</view>
<view class="mb-12rpx flex items-center text-26rpx text-[#666]">
<text class="mr-8rpx text-[#999]">用户编号</text>
<text>{{ item.userId }}</text>
</view>
<view class="flex items-center text-24rpx text-[#999]">
<text>{{ formatDateTime(item.exceptionTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无日志数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ApiErrorLog } from '@/api/infra/api-error-log'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getApiErrorLogPage } from '@/api/infra/api-error-log'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const total = ref(0)
const list = ref<ApiErrorLog[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询日志列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getApiErrorLogPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 查看详情 */
function handleDetail(item: ApiErrorLog) {
uni.navigateTo({
url: `/pages-infra/api-error-log/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,169 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
参数名称
</view>
<wd-input
v-model="formData.name"
placeholder="请输入参数名称"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
参数键名
</view>
<wd-input
v-model="formData.key"
placeholder="请输入参数键名"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
系统内置
</view>
<wd-radio-group v-model="formData.type" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_CONFIG_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined,
key: undefined as string | undefined,
type: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名称:${formData.name}`)
}
if (formData.key) {
conditions.push(`键名:${formData.key}`)
}
if (formData.type !== -1) {
conditions.push(`类型:${getDictLabel(DICT_TYPE.INFRA_CONFIG_TYPE, formData.type)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索参数配置'
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
name: formData.name || undefined,
key: formData.key || undefined,
type: formData.type === -1 ? undefined : formData.type,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.key = undefined
formData.type = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,133 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="参数配置详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="参数主键" :value="formData?.id" />
<wd-cell title="参数分类" :value="formData?.category" />
<wd-cell title="参数名称" :value="formData?.name" />
<wd-cell title="参数键名" :value="formData?.key" />
<wd-cell title="参数键值" :value="formData?.value" />
<wd-cell title="是否可见">
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="formData?.visible" />
</wd-cell>
<wd-cell title="系统内置">
<dict-tag :type="DICT_TYPE.INFRA_CONFIG_TYPE" :value="formData?.type" />
</wd-cell>
<wd-cell title="备注" :value="formData?.remark ?? '-'" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['infra:config:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['infra:config:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Config } from '@/api/infra/config'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteConfig, getConfig } from '@/api/infra/config'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<Config>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/config/index')
}
/** 加载参数配置详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getConfig(props.id)
} finally {
toast.close()
}
}
/** 编辑参数配置 */
function handleEdit() {
uni.navigateTo({
url: `/pages-infra/config/form/index?id=${props.id}`,
})
}
/** 删除参数配置 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该参数配置吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteConfig(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,167 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.category"
label="参数分类"
label-width="200rpx"
prop="category"
clearable
placeholder="请输入参数分类"
/>
<wd-input
v-model="formData.name"
label="参数名称"
label-width="200rpx"
prop="name"
clearable
placeholder="请输入参数名称"
/>
<wd-input
v-model="formData.key"
label="参数键名"
label-width="200rpx"
prop="key"
clearable
placeholder="请输入参数键名"
/>
<wd-input
v-model="formData.value"
label="参数键值"
label-width="200rpx"
prop="value"
clearable
placeholder="请输入参数键值"
/>
<wd-cell title="是否可见" title-width="200rpx" prop="visible" center>
<wd-radio-group v-model="formData.visible" shape="button">
<wd-radio :value="true">
</wd-radio>
<wd-radio :value="false">
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-textarea
v-model="formData.remark"
label="备注"
label-width="200rpx"
prop="remark"
clearable
placeholder="请输入备注"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { Config } from '@/api/infra/config'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createConfig, getConfig, updateConfig } from '@/api/infra/config'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑参数配置' : '新增参数配置')
const formLoading = ref(false)
const formData = ref<Config>({
id: undefined,
category: '',
name: '',
key: '',
value: '',
type: undefined,
visible: true,
remark: '',
})
const formRules = {
category: [{ required: true, message: '参数分类不能为空' }],
name: [{ required: true, message: '参数名称不能为空' }],
key: [{ required: true, message: '参数键名不能为空' }],
value: [{ required: true, message: '参数键值不能为空' }],
visible: [{ required: true, message: '是否可见不能为空' }],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/config/index')
}
/** 加载参数配置详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getConfig(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateConfig(formData.value)
toast.success('修改成功')
} else {
await createConfig(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,171 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="参数配置管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 参数配置列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.INFRA_CONFIG_TYPE" :value="item.type" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">参数分类</text>
<text class="min-w-0 flex-1 truncate">{{ item.category }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">参数键名</text>
<text class="min-w-0 flex-1 truncate">{{ item.key }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">参数键值</text>
<text class="min-w-0 flex-1 truncate">{{ item.value }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">是否可见</text>
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="item.visible" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text>{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无参数配置数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['infra:config:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { Config } from '@/api/infra/config'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getConfigPage } from '@/api/infra/config'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<Config[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询参数配置列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getConfigPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增参数配置 */
function handleAdd() {
uni.navigateTo({
url: '/pages-infra/config/form/index',
})
}
/** 查看详情 */
function handleDetail(item: Config) {
uni.navigateTo({
url: `/pages-infra/config/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,142 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="数据源配置详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="主键编号" :value="formData?.id" />
<wd-cell title="数据源名称" :value="formData?.name" />
<wd-cell title="数据源连接" is-link @click="handleCopyText(formData?.url, '数据源连接')">
<view class="max-w-400rpx truncate text-right">
{{ formData?.url }}
</view>
</wd-cell>
<wd-cell title="用户名" :value="formData?.username" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮主数据源不可编辑/删除 -->
<view v-if="formData?.id !== 0" class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['infra:data-source-config:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['infra:data-source-config:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { DataSourceConfig } from '@/api/infra/data-source-config'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteDataSourceConfig, getDataSourceConfig } from '@/api/infra/data-source-config'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<DataSourceConfig>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/data-source-config/index')
}
/** 复制文本并提示 */
function handleCopyText(text?: string, title?: string) {
if (!text || text === '-') {
return
}
uni.setClipboardData({
data: text,
success: () => {
uni.hideToast()
toast.success(`${title || '内容'}已复制`)
},
})
}
/** 加载数据源配置详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getDataSourceConfig(props.id)
} finally {
toast.close()
}
}
/** 编辑数据源配置 */
function handleEdit() {
uni.navigateTo({
url: `/pages-infra/data-source-config/form/index?id=${props.id}`,
})
}
/** 删除数据源配置 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该数据源配置吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteDataSourceConfig(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,146 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="数据源名称"
label-width="200rpx"
prop="name"
clearable
placeholder="请输入数据源名称"
/>
<wd-input
v-model="formData.url"
label="数据源连接"
label-width="200rpx"
prop="url"
clearable
placeholder="请输入数据源连接"
/>
<wd-input
v-model="formData.username"
label="用户名"
label-width="200rpx"
prop="username"
clearable
placeholder="请输入用户名"
/>
<wd-input
v-model="formData.password"
label="密码"
label-width="200rpx"
prop="password"
show-password
clearable
placeholder="请输入密码"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { DataSourceConfig } from '@/api/infra/data-source-config'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createDataSourceConfig, getDataSourceConfig, updateDataSourceConfig } from '@/api/infra/data-source-config'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑数据源' : '新增数据源')
const formLoading = ref(false)
const formData = ref<DataSourceConfig>({
id: undefined,
name: '',
url: '',
username: '',
password: '',
})
const formRules = {
name: [{ required: true, message: '数据源名称不能为空' }],
url: [{ required: true, message: '数据源连接不能为空' }],
username: [{ required: true, message: '用户名不能为空' }],
password: [{ required: true, message: '密码不能为空' }],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/data-source-config/index')
}
/** 加载数据源详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getDataSourceConfig(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateDataSourceConfig(formData.value)
toast.success('修改成功')
} else {
await createDataSourceConfig(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,122 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="数据源配置管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 数据源配置列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<view v-if="item.id === 0" class="rounded-4rpx bg-[#e6f7ff] px-12rpx py-4rpx text-24rpx text-[#1890ff]">
主数据源
</view>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">主键编号</text>
<text class="min-w-0 flex-1 truncate">{{ item.id }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">数据源连接</text>
<text class="min-w-0 flex-1 truncate">{{ item.url }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">用户名</text>
<text>{{ item.username }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text>{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="!loading && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无数据源配置数据" />
</view>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['infra:data-source-config:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { DataSourceConfig } from '@/api/infra/data-source-config'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getDataSourceConfigList } from '@/api/infra/data-source-config'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { formatDateTime } from '@/utils/date'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const list = ref<DataSourceConfig[]>([])
const loading = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询数据源配置列表 */
async function getList() {
loading.value = true
try {
toast.loading('加载中...')
list.value = await getDataSourceConfigList()
} finally {
loading.value = false
toast.close()
}
}
/** 新增数据源配置 */
function handleAdd() {
uni.navigateTo({
url: '/pages-infra/data-source-config/form/index',
})
}
/** 查看详情 */
function handleDetail(item: DataSourceConfig) {
uni.navigateTo({
url: `/pages-infra/data-source-config/detail/index?id=${item.id}`,
})
}
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,208 @@
<template>
<view>
<!-- 搜索组件 -->
<ConfigSearchForm @search="handleQuery" @reset="handleReset" />
<!-- 文件配置列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<view class="flex items-center gap-8rpx">
<view v-if="item.master" class="rounded-4rpx bg-green-500 px-8rpx py-2rpx text-24rpx text-white">
主配置
</view>
<dict-tag :type="DICT_TYPE.INFRA_FILE_STORAGE" :value="item.storage" />
</view>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">配置编号</text>
<text>{{ item.id }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">备注</text>
<text class="min-w-0 flex-1 truncate">{{ item.remark || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text>{{ formatDateTime(item.createTime) }}</text>
</view>
<!-- 操作按钮 -->
<view class="mt-16rpx flex justify-end gap-16rpx">
<wd-button
v-if="hasAccessByCodes(['infra:file-config:update']) && !item.master"
size="small" type="info" @click.stop="handleMaster(item)"
>
设为主配置
</wd-button>
<wd-button
v-if="hasAccessByCodes(['infra:file-config:update'])"
size="small" type="info" @click.stop="handleTest(item)"
>
测试
</wd-button>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无文件配置数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['infra:file-config:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { FileConfig } from '@/api/infra/file/config'
import type { LoadMoreState } from '@/http/types'
import { ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getFileConfigPage, testFileConfig, updateFileConfigMaster } from '@/api/infra/file/config'
import { useAccess } from '@/hooks/useAccess'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import ConfigSearchForm from './config-search-form.vue'
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const total = ref(0)
const list = ref<FileConfig[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 查询列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getFileConfigPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增 */
function handleAdd() {
uni.navigateTo({
url: '/pages-infra/file/config/form/index',
})
}
/** 查看详情 */
function handleDetail(item: FileConfig) {
uni.navigateTo({
url: `/pages-infra/file/config/detail/index?id=${item.id}`,
})
}
/** 测试文件配置 */
async function handleTest(item: FileConfig) {
toast.loading('测试上传中...')
const url = await testFileConfig(item.id!)
toast.close()
uni.showModal({
title: '测试上传成功',
content: '是否要访问该文件?',
confirmText: '访问',
success: (res) => {
if (!res.confirm || !url) {
return
}
// 复制链接到剪贴板
uni.setClipboardData({
data: url,
success: () => {
uni.hideToast()
toast.success('链接已复制,请在浏览器中打开')
},
})
},
})
}
/** 设为主配置 */
function handleMaster(item: FileConfig) {
uni.showModal({
title: '提示',
content: `是否要将"${item.name}"设为主配置?`,
success: async (res) => {
if (!res.confirm) {
return
}
try {
toast.loading('设置中...')
await updateFileConfigMaster(item.id!)
toast.success('设置成功')
// 刷新列表
handleQuery()
} catch {
toast.close()
}
},
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,153 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
配置名
</view>
<wd-input
v-model="formData.name"
placeholder="请输入配置名"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
存储器
</view>
<wd-radio-group v-model="formData.storage" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined,
storage: -1,
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`配置名:${formData.name}`)
}
if (formData.storage !== -1) {
conditions.push(`存储器:${getDictLabel(DICT_TYPE.INFRA_FILE_STORAGE, formData.storage)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索文件配置'
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
name: formData.name || undefined,
storage: formData.storage === -1 ? undefined : formData.storage,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.storage = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,225 @@
<template>
<view>
<!-- 搜索组件 -->
<FileSearchForm @search="handleQuery" @reset="handleReset" />
<!-- 文件列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="line-clamp-1 text-32rpx text-[#333] font-semibold">
{{ item.name || item.path }}
</view>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">文件路径</text>
<text class="min-w-0 flex-1 truncate">{{ item.path }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">文件类型</text>
<text class="min-w-0 flex-1 truncate">{{ item.type }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">文件大小</text>
<text>{{ formatFileSize(item.size) }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">上传时间</text>
<text>{{ formatDateTime(item.createTime) }}</text>
</view>
<view v-if="item.type && item.type.includes('image')" class="mb-12rpx">
<wd-img
:src="item.url"
mode="aspectFit"
width="100%"
height="200rpx"
enable-preview
@click.stop
/>
</view>
<!-- 操作按钮 -->
<view class="mt-16rpx flex justify-end gap-16rpx">
<wd-button size="small" type="info" @click.stop="handleCopyUrl(item)">
复制链接
</wd-button>
<wd-button
v-if="hasAccessByCodes(['infra:file:delete'])"
size="small" type="error" @click.stop="handleDelete(item)"
>
删除
</wd-button>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无文件数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 上传按钮 -->
<wd-fab
position="right-bottom"
type="primary"
:expandable="false"
@click="handleUpload"
/>
</view>
</template>
<script lang="ts" setup>
import type { LoadMoreState } from '@/http/types'
import { ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { uploadFile } from '@/api/infra/file'
import { useAccess } from '@/hooks/useAccess'
import { http } from '@/http/http'
import { formatDateTime } from '@/utils/date'
import { formatFileSize } from '@/utils/download'
import FileSearchForm from './file-search-form.vue'
/** 文件信息 */
interface FileInfo {
id?: number
configId?: number
path: string
name?: string
url?: string
size?: number
type?: string
createTime?: Date
}
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const total = ref(0)
const list = ref<FileInfo[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 查询列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await http.get<{ list: FileInfo[], total: number }>('/infra/file/page', queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 上传文件 */
function handleUpload() {
uni.chooseImage({
count: 1,
success: async (res) => {
const filePath = res.tempFilePaths[0]
try {
toast.loading('上传中...')
await uploadFile(filePath)
toast.success('上传成功')
// 刷新列表
handleQuery()
} catch {
toast.show('上传失败')
}
},
})
}
/** 复制链接 */
function handleCopyUrl(item: FileInfo) {
if (!item.url) {
toast.show('文件 URL 为空')
return
}
uni.setClipboardData({
data: item.url,
success: () => {
toast.success('复制成功')
},
})
}
/** 查看详情 */
function handleDetail(item: FileInfo) {
uni.navigateTo({
url: `/pages-infra/file/detail/index?id=${item.id}`,
})
}
/** 删除文件 */
function handleDelete(item: FileInfo) {
uni.showModal({
title: '提示',
content: `确定要删除文件"${item.name || item.path}"吗?`,
success: async (res) => {
if (!res.confirm) {
return
}
try {
toast.loading('删除中...')
await http.delete(`/infra/file/delete?id=${item.id}`)
toast.success('删除成功')
// 刷新列表
handleQuery()
} catch {
toast.show('删除失败')
}
},
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,144 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
文件路径
</view>
<wd-input
v-model="formData.path"
placeholder="请输入文件路径"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
文件类型
</view>
<wd-input
v-model="formData.type"
placeholder="请输入文件类型"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getNavbarHeight } from '@/utils'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
path: undefined as string | undefined,
type: undefined as string | undefined,
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.path) {
conditions.push(`路径:${formData.path}`)
}
if (formData.type) {
conditions.push(`类型:${formData.type}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索文件'
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 创建时间[0]确认 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 创建时间[1]确认 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
path: formData.path || undefined,
type: formData.type || undefined,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.path = undefined
formData.type = undefined
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,157 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="文件配置详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="配置编号" :value="formData?.id" />
<wd-cell title="配置名" :value="formData?.name" />
<wd-cell title="存储器">
<dict-tag :type="DICT_TYPE.INFRA_FILE_STORAGE" :value="formData?.storage" />
</wd-cell>
<wd-cell title="主配置">
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="formData?.master" />
</wd-cell>
<wd-cell title="备注" :value="formData?.remark ?? '-'" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
<!-- 存储配置详情 -->
<wd-cell-group v-if="formData?.config" border title="存储配置">
<!-- DB / Local / FTP / SFTP 配置 -->
<template v-if="formData.storage && formData.storage >= 10 && formData.storage <= 12">
<wd-cell title="基础路径" :value="formData.config.basePath ?? '-'" />
<template v-if="formData.storage >= 11 && formData.storage <= 12">
<wd-cell title="主机地址" :value="formData.config.host ?? '-'" />
<wd-cell title="主机端口" :value="formData.config.port ?? '-'" />
<wd-cell title="用户名" :value="formData.config.username ?? '-'" />
<wd-cell title="密码" :value="formData.config.password ?? '-'" />
</template>
<wd-cell v-if="formData.storage === 11" title="连接模式" :value="formData.config.mode === 'Active' ? '主动模式' : '被动模式'" />
</template>
<!-- S3 配置 -->
<template v-if="formData.storage === 20">
<wd-cell title="节点地址" :value="formData.config.endpoint" />
<wd-cell title="存储 bucket" :value="formData.config.bucket" />
<wd-cell title="accessKey" :value="formData.config.accessKey" />
<wd-cell title="accessSecret" :value="formData.config.accessSecret" />
<wd-cell title="Path Style" :value="formData.config.enablePathStyleAccess ? '启用' : '禁用'" />
<wd-cell title="公开访问" :value="formData.config.enablePublicAccess ? '公开' : '私有'" />
<wd-cell title="区域" :value="formData.config.region ?? '-'" />
</template>
<!-- 通用配置 -->
<wd-cell title="自定义域名" :value="formData.config.domain ?? '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['infra:file-config:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['infra:file-config:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FileConfig } from '@/api/infra/file/config'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteFileConfig, getFileConfig } from '@/api/infra/file/config'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<FileConfig>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/file/index')
}
/** 加载详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getFileConfig(props.id)
} finally {
toast.close()
}
}
/** 编辑 */
function handleEdit() {
uni.navigateTo({
url: `/pages-infra/file/config/form/index?id=${props.id}`,
})
}
/** 删除 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该文件配置吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteFileConfig(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,308 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="配置名"
label-width="200rpx"
prop="name"
clearable
placeholder="请输入配置名"
/>
<wd-cell title="存储器" title-width="200rpx" prop="storage" center>
<wd-picker
v-model="formData.storage"
:columns="getIntDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)"
label-key="label"
value-key="value"
:disabled="!!formData.id"
placeholder="请选择存储器"
/>
</wd-cell>
<wd-textarea
v-model="formData.remark"
label="备注"
label-width="200rpx"
prop="remark"
clearable
placeholder="请输入备注"
/>
</wd-cell-group>
<!-- DB / Local / FTP / SFTP 配置 -->
<wd-cell-group v-if="formData.storage && formData.storage >= 10 && formData.storage <= 12" border title="存储配置">
<wd-input
v-model="formData.config!.basePath"
label="基础路径"
label-width="200rpx"
prop="config.basePath"
clearable
placeholder="请输入基础路径"
/>
<!-- FTP / SFTP 配置 -->
<template v-if="formData.storage >= 11 && formData.storage <= 12">
<wd-input
v-model="formData.config!.host"
label="主机地址"
label-width="200rpx"
prop="config.host"
clearable
placeholder="请输入主机地址"
/>
<wd-input
v-model.number="formData.config!.port"
label="主机端口"
label-width="200rpx"
prop="config.port"
type="number"
clearable
placeholder="请输入主机端口"
/>
<wd-input
v-model="formData.config!.username"
label="用户名"
label-width="200rpx"
prop="config.username"
clearable
placeholder="请输入用户名"
/>
<wd-input
v-model="formData.config!.password"
label="密码"
label-width="200rpx"
prop="config.password"
clearable
placeholder="请输入密码"
/>
</template>
<!-- FTP 连接模式 -->
<wd-cell v-if="formData.storage === 11" title="连接模式" title-width="200rpx" prop="config.mode" center>
<wd-radio-group v-model="formData.config!.mode" shape="button">
<wd-radio value="Active">
主动模式
</wd-radio>
<wd-radio value="Passive">
被动模式
</wd-radio>
</wd-radio-group>
</wd-cell>
</wd-cell-group>
<!-- S3 配置 -->
<wd-cell-group v-if="formData.storage === 20" border title="S3 配置">
<wd-input
v-model="formData.config!.endpoint"
label="节点地址"
label-width="200rpx"
prop="config.endpoint"
clearable
placeholder="请输入节点地址"
/>
<wd-input
v-model="formData.config!.bucket"
label="存储 bucket"
label-width="200rpx"
prop="config.bucket"
clearable
placeholder="请输入 bucket"
/>
<wd-input
v-model="formData.config!.accessKey"
label="accessKey"
label-width="200rpx"
prop="config.accessKey"
clearable
placeholder="请输入 accessKey"
/>
<wd-input
v-model="formData.config!.accessSecret"
label="accessSecret"
label-width="200rpx"
prop="config.accessSecret"
clearable
placeholder="请输入 accessSecret"
/>
<wd-cell title="Path Style" title-width="200rpx" prop="config.enablePathStyleAccess" center>
<wd-radio-group v-model="formData.config!.enablePathStyleAccess" shape="button">
<wd-radio :value="true">
启用
</wd-radio>
<wd-radio :value="false">
禁用
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="公开访问" title-width="200rpx" prop="config.enablePublicAccess" center>
<wd-radio-group v-model="formData.config!.enablePublicAccess" shape="button">
<wd-radio :value="true">
公开
</wd-radio>
<wd-radio :value="false">
私有
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-input
v-model="formData.config!.region"
label="区域"
label-width="200rpx"
prop="config.region"
clearable
placeholder="请填写区域,一般仅 AWS 需要填写"
/>
</wd-cell-group>
<!-- 通用配置 -->
<wd-cell-group v-if="formData.storage" border title="通用配置">
<wd-input
v-model="formData.config!.domain"
label="自定义域名"
label-width="200rpx"
prop="config.domain"
clearable
placeholder="请输入自定义域名"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { FileConfig } from '@/api/infra/file/config'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createFileConfig, getFileConfig, updateFileConfig } from '@/api/infra/file/config'
import { getIntDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑文件配置' : '新增文件配置')
const formLoading = ref(false)
const formData = ref<FileConfig>({
id: undefined,
name: '',
storage: undefined,
remark: '',
config: {
basePath: '',
host: '',
port: undefined,
username: '',
password: '',
mode: 'Passive',
endpoint: '',
bucket: '',
accessKey: '',
accessSecret: '',
enablePathStyleAccess: false,
enablePublicAccess: false,
region: '',
domain: '',
},
})
const formRules = {
name: [{ required: true, message: '配置名不能为空' }],
storage: [{ required: true, message: '存储器不能为空' }],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/file/index')
}
/** 加载详情 */
async function getDetail() {
if (!props.id) {
return
}
const data = await getFileConfig(props.id)
formData.value = {
...data,
config: data.config || {
basePath: '',
host: '',
port: undefined,
username: '',
password: '',
mode: 'Passive',
endpoint: '',
bucket: '',
accessKey: '',
accessSecret: '',
enablePathStyleAccess: false,
enablePublicAccess: false,
region: '',
domain: '',
},
}
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateFileConfig(formData.value)
toast.success('修改成功')
} else {
await createFileConfig(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,145 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="文件详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="文件编号" :value="formData?.id" />
<wd-cell title="文件名" :value="formData?.name" />
<wd-cell title="文件路径" :value="formData?.path" />
<wd-cell title="文件 URL" :value="formData?.url" />
<wd-cell title="文件大小" :value="formatFileSize(formData?.size)" />
<wd-cell title="文件类型" :value="formData?.type" />
<wd-cell title="上传时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
<!-- 文件预览 -->
<view v-if="formData?.type && formData.type.includes('image')" class="m-24rpx">
<view class="mb-16rpx text-28rpx text-[#999]">
文件预览
</view>
<wd-img
:src="formData.url"
mode="aspectFit"
width="100%"
height="400rpx"
enable-preview
/>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button class="flex-1" type="info" @click="handleCopyUrl">
复制链接
</wd-button>
<wd-button
v-if="hasAccessByCodes(['infra:file:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FileVO } from '@/api/infra/file'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteFile, getFile } from '@/api/infra/file'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { formatDateTime } from '@/utils/date'
import { formatFileSize } from '@/utils/download'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<FileVO>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/file/index')
}
/** 加载详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getFile(props.id)
} finally {
toast.close()
}
}
/** 复制链接 */
function handleCopyUrl() {
if (!formData.value?.url) {
toast.show('文件 URL 为空')
return
}
uni.setClipboardData({
data: formData.value.url,
success: () => {
uni.hideToast()
toast.success('复制成功')
},
})
}
/** 删除 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该文件吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteFile(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,52 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="文件管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- Tab 切换 -->
<view class="bg-white">
<wd-tabs v-model="tabIndex" shrink @change="handleTabChange">
<wd-tab title="文件列表" />
<wd-tab title="文件配置" />
</wd-tabs>
</view>
<!-- 列表内容 -->
<FileList v-show="tabType === 'file'" />
<ConfigList v-show="tabType === 'config'" />
</view>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { navigateBackPlus } from '@/utils'
import ConfigList from './components/config-list.vue'
import FileList from './components/file-list.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const tabTypes: string[] = ['file', 'config']
const tabIndex = ref(0)
const tabType = computed<string>(() => tabTypes[tabIndex.value])
/** Tab 切换 */
function handleTabChange({ index }: { index: number }) {
tabIndex.value = index
}
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,158 @@
<template>
<view>
<!-- 搜索组件 -->
<JobSearchForm @search="handleQuery" @reset="handleReset" />
<!-- 任务列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.INFRA_JOB_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">处理器名称</text>
<text class="min-w-0 flex-1 truncate">{{ item.handlerName }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">处理器参数</text>
<text class="min-w-0 flex-1 truncate">{{ item.handlerParam || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">CRON 表达式</text>
<text class="min-w-0 flex-1 truncate">{{ item.cronExpression }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text>{{ formatDateTime(item.createTime) }}</text>
</view>
<!-- 查看日志按钮 -->
<view class="flex justify-end -mt-8">
<wd-button size="small" type="info" @click.stop="handleViewLog(item)">
调度日志
</wd-button>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无定时任务数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['infra:job:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { Job } from '@/api/infra/job'
import type { LoadMoreState } from '@/http/types'
import { ref } from 'vue'
import { getJobPage } from '@/api/infra/job'
import { useAccess } from '@/hooks/useAccess'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import JobSearchForm from './job-search-form.vue'
const emit = defineEmits<{
viewLog: [jobId: number]
}>()
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<Job[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 查询列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getJobPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增 */
function handleAdd() {
uni.navigateTo({
url: '/pages-infra/job/job/form/index',
})
}
/** 查看详情 */
function handleDetail(item: Job) {
uni.navigateTo({
url: `/pages-infra/job/job/detail/index?id=${item.id}`,
})
}
/** 查看调度日志 */
function handleViewLog(item: Job) {
emit('viewLog', item.id)
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,110 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
任务名称
</view>
<wd-input
v-model="formData.name"
placeholder="请输入任务名称"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
处理器名称
</view>
<wd-input
v-model="formData.handlerName"
placeholder="请输入处理器名称"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
任务状态
</view>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_JOB_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined,
handlerName: undefined as string | undefined,
status: -1, // -1 表示全部
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`任务名称:${formData.name}`)
}
if (formData.handlerName) {
conditions.push(`处理器:${formData.handlerName}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.INFRA_JOB_STATUS, formData.status)}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索定时任务'
})
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
name: formData.name || undefined,
handlerName: formData.handlerName || undefined,
status: formData.status === -1 ? undefined : formData.status,
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.handlerName = undefined
formData.status = -1
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,141 @@
<template>
<view>
<!-- 搜索组件 -->
<LogSearchForm :job-id="jobId" @search="handleQuery" @reset="handleReset" />
<!-- 日志列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.handlerName }}
</view>
<dict-tag :type="DICT_TYPE.INFRA_JOB_LOG_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">任务编号</text>
<text class="min-w-0 flex-1 truncate">{{ item.jobId }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">处理器参数</text>
<text class="min-w-0 flex-1 truncate">{{ item.handlerParam || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">执行时长</text>
<text>{{ item.duration ? `${item.duration} ms` : '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">开始时间</text>
<text>{{ formatDateTime(item.beginTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无调度日志数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import type { JobLog } from '@/api/infra/job/log'
import type { LoadMoreState } from '@/http/types'
import { ref, watch } from 'vue'
import { getJobLogPage } from '@/api/infra/job/log'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import LogSearchForm from './log-search-form.vue'
const props = defineProps<{
jobId?: number
}>()
const total = ref(0)
const list = ref<JobLog[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 查询列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getJobLogPage(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 查看详情 */
function handleDetail(item: JobLog) {
uni.navigateTo({
url: `/pages-infra/job/log/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
/** 监听 jobId 变化,重新查询 */
watch(
() => props.jobId,
() => {
if (props.jobId) {
queryParams.value.pageNo = 1
list.value = []
getList()
}
},
)
</script>

View File

@@ -0,0 +1,182 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
任务编号
</view>
<wd-input
v-model="formData.jobId"
placeholder="请输入任务编号"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
处理器名称
</view>
<wd-input
v-model="formData.handlerName"
placeholder="请输入处理器名称"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
执行状态
</view>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_JOB_LOG_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
开始时间
</view>
<view class="yd-search-form-date-range-container">
<view class="flex-1" @click="visibleBeginTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.beginTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view class="flex-1" @click="visibleBeginTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.beginTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleBeginTime[0]" v-model="tempBeginTime[0]" type="date" />
<view v-if="visibleBeginTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleBeginTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleBeginTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleBeginTime[1]" v-model="tempBeginTime[1]" type="date" />
<view v-if="visibleBeginTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleBeginTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleBeginTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDate, formatDateRange } from '@/utils/date'
const props = defineProps<{
jobId?: number
}>()
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
jobId: undefined as number | undefined,
handlerName: undefined as string | undefined,
status: -1, // -1 表示全部
beginTime: [undefined, undefined] as [number | undefined, number | undefined],
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.jobId) {
conditions.push(`任务编号:${formData.jobId}`)
}
if (formData.handlerName) {
conditions.push(`处理器:${formData.handlerName}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.INFRA_JOB_LOG_STATUS, formData.status)}`)
}
if (formData.beginTime?.[0] && formData.beginTime?.[1]) {
conditions.push(`时间:${formatDate(formData.beginTime[0])}~${formatDate(formData.beginTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索调度日志'
})
// 时间范围选择器状态
const visibleBeginTime = ref<[boolean, boolean]>([false, false])
const tempBeginTime = ref<[number, number]>([Date.now(), Date.now()])
/** 开始时间[0]确认 */
function handleBeginTime0Confirm() {
formData.beginTime = [tempBeginTime.value[0], formData.beginTime?.[1]]
visibleBeginTime.value[0] = false
}
/** 开始时间[1]确认 */
function handleBeginTime1Confirm() {
formData.beginTime = [formData.beginTime?.[0], tempBeginTime.value[1]]
visibleBeginTime.value[1] = false
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
jobId: formData.jobId || undefined,
handlerName: formData.handlerName || undefined,
status: formData.status === -1 ? undefined : formData.status,
beginTime: formatDateRange(formData.beginTime),
})
}
/** 重置 */
function handleReset() {
formData.jobId = undefined
formData.handlerName = undefined
formData.status = -1
formData.beginTime = [undefined, undefined]
visible.value = false
emit('reset')
}
/** 监听外部 jobId 变化 */
watch(
() => props.jobId,
(val) => {
formData.jobId = val
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,75 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="定时任务"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- Tab 切换 -->
<view class="bg-white">
<wd-tabs v-model="tabIndex" shrink @change="handleTabChange">
<wd-tab title="定时任务" />
<wd-tab title="调度日志" />
</wd-tabs>
</view>
<!-- 列表内容 -->
<JobList v-show="tabType === 'job'" @view-log="handleViewLog" />
<LogList v-show="tabType === 'log'" :job-id="selectedJobId" />
</view>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { navigateBackPlus } from '@/utils'
import JobList from './components/job-list.vue'
import LogList from './components/log-list.vue'
const props = defineProps<{
tab?: string
jobId?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const tabTypes: string[] = ['job', 'log']
const tabIndex = ref(0)
const tabType = computed<string>(() => tabTypes[tabIndex.value])
const selectedJobId = ref<number>() // 选中的任务 ID
/** Tab 切换 */
function handleTabChange({ index }: { index: number }) {
tabIndex.value = index
}
/** 查看调度日志 */
function handleViewLog(jobId: number) {
selectedJobId.value = jobId
tabIndex.value = 1 // 切换到调度日志 tab
}
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 初始化 */
onMounted(() => {
// 支持通过 URL 参数切换到日志 tab
if (props.tab === 'log') {
tabIndex.value = 1
if (props.jobId) {
selectedJobId.value = Number(props.jobId)
}
}
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,229 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="定时任务详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="任务编号" :value="formData?.id" />
<wd-cell title="任务名称" :value="formData?.name" />
<wd-cell title="任务状态">
<dict-tag :type="DICT_TYPE.INFRA_JOB_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="处理器名称" :value="formData?.handlerName" />
<wd-cell title="处理器参数" :value="formData?.handlerParam ?? '-'" />
<wd-cell title="CRON 表达式" :value="formData?.cronExpression" />
<wd-cell title="重试次数" :value="formData?.retryCount" />
<wd-cell title="重试间隔" :value="formData?.retryInterval ? `${formData.retryInterval} ms` : '-'" />
<wd-cell title="监控超时" :value="formData?.monitorTimeout ? `${formData.monitorTimeout} ms` : '-'" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['infra:job:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['infra:job:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
<wd-button
v-if="hasMoreActions"
class="flex-1" type="info" @click="moreActionVisible = true"
>
更多
</wd-button>
</view>
</view>
<!-- 更多操作菜单 -->
<wd-action-sheet v-model="moreActionVisible" :actions="moreActions" @select="handleMoreAction" />
</view>
</template>
<script lang="ts" setup>
import type { Job } from '@/api/infra/job'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteJob, getJob, runJob, updateJobStatus } from '@/api/infra/job'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE, InfraJobStatusEnum } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<Job>()
const deleting = ref(false)
const moreActionVisible = ref(false) // 更多操作菜单
const moreActions = computed(() => {
const actions = []
// 执行一次权限
if (hasAccessByCodes(['infra:job:trigger'])) {
actions.push({ name: '执行一次', value: 'run' })
}
// 更新状态权限
if (hasAccessByCodes(['infra:job:update'])) {
const isRunning = formData.value?.status === InfraJobStatusEnum.NORMAL
actions.push({ name: isRunning ? '暂停任务' : '开启任务', value: 'update-status' })
}
// 查看调度日志权限
if (hasAccessByCodes(['infra:job:query'])) {
actions.push({ name: '调度日志', value: 'view-log' })
}
return actions
})
const hasMoreActions = computed(() => moreActions.value.length > 0)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/job/index')
}
/** 加载详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getJob(props.id)
} finally {
toast.close()
}
}
/** 执行一次 */
function handleRun() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要立即执行一次该任务吗?',
success: async (res) => {
if (!res.confirm) {
return
}
try {
toast.loading('执行中...')
await runJob(props.id)
toast.success('执行成功')
} finally {
toast.close()
}
},
})
}
/** 编辑 */
function handleEdit() {
uni.navigateTo({
url: `/pages-infra/job/job/form/index?id=${props.id}`,
})
}
/** 删除 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该定时任务吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteJob(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 更多操作 */
function handleMoreAction({ item }: { item: { value: string } }) {
if (item.value === 'run') {
handleRun()
} else if (item.value === 'update-status') {
handleUpdateStatus()
} else if (item.value === 'view-log') {
handleViewLog()
}
}
/** 更新任务状态 */
function handleUpdateStatus() {
if (!props.id) {
return
}
const isRunning = formData.value?.status === InfraJobStatusEnum.NORMAL
const statusText = isRunning ? '暂停' : '开启'
uni.showModal({
title: '提示',
content: `确定要${statusText}该任务吗?`,
success: async (res) => {
if (!res.confirm) {
return
}
try {
toast.loading(`正在${statusText}中...`)
const newStatus = isRunning ? InfraJobStatusEnum.STOP : InfraJobStatusEnum.NORMAL
await updateJobStatus(props.id, newStatus)
toast.success(`${statusText}成功`)
await getDetail()
} finally {
toast.close()
}
},
})
}
/** 查看调度日志 */
function handleViewLog() {
uni.navigateTo({
url: `/pages-infra/job/index?tab=log&jobId=${props.id}`,
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,178 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="任务名称"
label-width="200rpx"
prop="name"
clearable
placeholder="请输入任务名称"
/>
<wd-input
v-model="formData.handlerName"
label="处理器名称"
label-width="200rpx"
prop="handlerName"
clearable
placeholder="请输入处理器名称"
/>
<wd-input
v-model="formData.handlerParam"
label="处理器参数"
label-width="200rpx"
prop="handlerParam"
clearable
placeholder="请输入处理器参数"
/>
<wd-input
v-model="formData.cronExpression"
label="CRON 表达式"
label-width="200rpx"
prop="cronExpression"
clearable
placeholder="请输入 CRON 表达式"
/>
<wd-input
v-model.number="formData.retryCount"
label="重试次数"
label-width="200rpx"
prop="retryCount"
type="number"
clearable
placeholder="请输入重试次数"
/>
<wd-input
v-model.number="formData.retryInterval"
label="重试间隔(ms)"
label-width="200rpx"
prop="retryInterval"
type="number"
clearable
placeholder="请输入重试间隔"
/>
<wd-input
v-model.number="formData.monitorTimeout"
label="监控超时(ms)"
label-width="200rpx"
prop="monitorTimeout"
type="number"
clearable
placeholder="请输入监控超时时间"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { Job } from '@/api/infra/job'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createJob, getJob, updateJob } from '@/api/infra/job'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum } from '@/utils/constants/biz-system-enum'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑定时任务' : '新增定时任务')
const formLoading = ref(false)
const formData = ref<Job>({
id: undefined,
name: '',
status: CommonStatusEnum.ENABLE,
handlerName: '',
handlerParam: '',
cronExpression: '',
retryCount: 0,
retryInterval: 0,
monitorTimeout: 0,
})
const formRules = {
name: [{ required: true, message: '任务名称不能为空' }],
handlerName: [{ required: true, message: '处理器名称不能为空' }],
cronExpression: [{ required: true, message: 'CRON 表达式不能为空' }],
retryCount: [{ required: true, message: '重试次数不能为空' }],
retryInterval: [{ required: true, message: '重试间隔不能为空' }],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/job/index')
}
/** 加载详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getJob(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateJob(formData.value)
toast.success('修改成功')
} else {
await createJob(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,80 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="调度日志详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="日志编号" :value="formData?.id" />
<wd-cell title="任务编号" :value="formData?.jobId" />
<wd-cell title="处理器名称" :value="formData?.handlerName" />
<wd-cell title="处理器参数" :value="formData?.handlerParam ?? '-'" />
<wd-cell title="CRON 表达式" :value="formData?.cronExpression" />
<wd-cell title="执行索引" :value="formData?.executeIndex" />
<wd-cell title="执行状态">
<dict-tag :type="DICT_TYPE.INFRA_JOB_LOG_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="开始时间" :value="formatDateTime(formData?.beginTime)" />
<wd-cell title="结束时间" :value="formatDateTime(formData?.endTime)" />
<wd-cell title="执行时长" :value="formData?.duration ? `${formData.duration} ms` : '-'" />
<wd-cell title="执行结果" :value="formData?.result" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
</view>
</template>
<script lang="ts" setup>
import type { JobLog } from '@/api/infra/job/log'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getJobLog } from '@/api/infra/job/log'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formData = ref<JobLog>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/job/index')
}
/** 加载详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getJobLog(Number(props.id))
} finally {
toast.close()
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,491 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="WebSocket 测试"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 连接状态卡片 -->
<view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white shadow-sm">
<view class="p-32rpx">
<!-- 状态指示器 -->
<view class="mb-24rpx flex items-center">
<view
class="mr-16rpx h-20rpx w-20rpx rounded-full"
:class="isConnected ? 'bg-[#07c160]' : 'bg-[#fa5151]'"
/>
<text class="text-32rpx text-[#333] font-semibold">连接管理</text>
</view>
<!-- 连接状态 -->
<view class="mb-24rpx flex items-center rounded-12rpx bg-[#f7f8fa] p-24rpx">
<text class="mr-16rpx text-28rpx text-[#666]">连接状态:</text>
<wd-tag :type="isConnected ? 'success' : 'danger'">
{{ statusText }}
</wd-tag>
</view>
<!-- 服务地址 -->
<view class="mb-24rpx">
<text class="mb-12rpx block text-26rpx text-[#999]">服务地址</text>
<view class="rounded-12rpx bg-[#f7f8fa] p-24rpx">
<text class="break-all text-26rpx text-[#666]">{{ serverUrl }}</text>
</view>
</view>
<!-- 连接按钮 -->
<wd-button
block
:type="isConnected ? 'error' : 'primary'"
@click="toggleConnection"
>
{{ isConnected ? '断开连接' : '建立连接' }}
</wd-button>
</view>
</view>
<!-- 发送消息卡片 -->
<view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white shadow-sm">
<view class="p-32rpx">
<view class="mb-24rpx flex items-center">
<wd-icon name="chat" size="36rpx" color="#1989fa" class="mr-12rpx" />
<text class="text-32rpx text-[#333] font-semibold">发送消息</text>
</view>
<!-- 接收人选择 -->
<view class="mb-24rpx">
<text class="mb-12rpx block text-26rpx text-[#999]">接收人</text>
<wd-picker
v-model="sendUserId"
:columns="userColumns"
:disabled="!isConnected"
@confirm="handleUserChange"
>
<view class="flex items-center justify-between rounded-12rpx bg-[#f7f8fa] p-24rpx">
<text class="text-28rpx" :class="isConnected ? 'text-[#333]' : 'text-[#c8c9cc]'">
{{ selectedUserLabel }}
</text>
<wd-icon name="arrow-down" size="32rpx" :color="isConnected ? '#666' : '#c8c9cc'" />
</view>
</wd-picker>
</view>
<!-- 消息内容 -->
<view class="mb-24rpx">
<text class="mb-12rpx block text-26rpx text-[#999]">消息内容</text>
<wd-textarea
v-model="sendText"
placeholder="请输入要发送的消息..."
:disabled="!isConnected"
:maxlength="500"
show-word-limit
auto-height
:min-height="120"
/>
</view>
<!-- 发送按钮 -->
<wd-button
block
type="primary"
:disabled="!isConnected || !sendText.trim()"
@click="handleSend"
>
<wd-icon name="send" size="32rpx" class="mr-8rpx" />
发送消息
</wd-button>
</view>
</view>
<!-- 消息记录卡片 -->
<view class="mx-24rpx mb-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white shadow-sm">
<view class="p-32rpx">
<view class="mb-24rpx flex items-center justify-between">
<view class="flex items-center">
<wd-icon name="list" size="36rpx" color="#1989fa" class="mr-12rpx" />
<text class="text-32rpx text-[#333] font-semibold">消息记录</text>
<wd-tag v-if="messageList.length > 0" type="primary" plain class="ml-16rpx">
{{ messageList.length }}
</wd-tag>
</view>
<wd-button
v-if="messageList.length > 0"
size="small"
type="error"
plain
@click="clearMessages"
>
清空
</wd-button>
</view>
<!-- 消息列表 -->
<scroll-view
scroll-y
class="message-list rounded-12rpx bg-[#f7f8fa]"
:style="{ height: '600rpx' }"
>
<view v-if="messageList.length === 0" class="h-full flex flex-col items-center justify-center">
<wd-icon name="inbox" size="80rpx" color="#c8c9cc" />
<text class="mt-16rpx text-26rpx text-[#c8c9cc]">暂无消息记录</text>
</view>
<view v-else class="p-20rpx">
<view
v-for="(msg, index) in messageReverseList"
:key="index"
class="mb-20rpx rounded-12rpx bg-white p-24rpx shadow-sm"
>
<view class="mb-12rpx flex items-center justify-between">
<view class="flex items-center">
<view
class="mr-12rpx h-16rpx w-16rpx rounded-full"
:style="{ backgroundColor: getMessageBadgeColor(msg.type) }"
/>
<text class="text-26rpx text-[#666] font-medium">
{{ getMessageTypeText(msg.type) }}
</text>
<text v-if="msg.userId" class="ml-16rpx text-24rpx text-[#999]">
用户 ID: {{ msg.userId }}
</text>
</view>
<text class="text-22rpx text-[#c8c9cc]">
{{ formatDateTime(msg.time) }}
</text>
</view>
<view class="break-words text-28rpx text-[#333]">
{{ msg.text }}
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 底部安全区域 -->
<view class="h-env(safe-area-inset-bottom)" />
</view>
</template>
<script lang="ts" setup>
import type { User } from '@/api/system/user'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getSimpleUserList } from '@/api/system/user'
import { useTokenStore } from '@/store/token'
import { getEnvBaseUrlRoot, navigateBackPlus } from '@/utils'
import { formatDateTime } from '@/utils/date'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
// ======================= 状态定义 =======================
const tokenStore = useTokenStore()
const toast = useToast()
// WebSocket 相关状态
const socketTask = ref<UniApp.SocketTask | null>(null)
const isConnected = ref(false)
const statusText = computed(() => (isConnected.value ? '已连接' : '未连接'))
// 服务地址
const serverUrl = computed(() => {
const baseUrl = getEnvBaseUrlRoot()
const wsUrl = baseUrl.replace('http', 'ws')
const tokenInfo = tokenStore.tokenInfo as any
const token = tokenInfo?.refreshToken || tokenStore.validToken
return `${wsUrl}/infra/ws?token=${token}`
})
// 消息相关状态
interface Message {
text: string
time: number
type?: 'single' | 'group' | 'system'
userId?: string
}
const messageList = ref<Message[]>([])
const messageReverseList = computed(() => [...messageList.value].reverse())
// 发送消息相关
const sendText = ref('')
const sendUserId = ref('all')
const userList = ref<User[]>([])
const userColumns = computed(() => {
const list = [
{ value: 'all', label: '所有人' },
...userList.value.map(user => ({
value: String(user.id),
label: user.nickname || user.username,
})),
]
return [list]
}) // 用户选择器列表
const selectedUserLabel = computed(() => {
if (sendUserId.value === 'all') {
return '所有人'
}
const user = userList.value.find(u => String(u.id) === sendUserId.value)
return user?.nickname || user?.username || '所有人'
}) // 选中的用户标签
// ======================= WebSocket 方法 =======================
/** 建立 WebSocket 连接 */
function connect() {
if (socketTask.value) {
return
}
// 1.1 发起连接请求
socketTask.value = uni.connectSocket({
url: serverUrl.value,
success: () => {
console.log('WebSocket 连接请求已发送')
},
fail: (err) => {
console.error('WebSocket 连接失败:', err)
toast.error('连接失败')
},
})
// 1.2 监听连接打开
socketTask.value.onOpen(() => {
console.log('WebSocket 连接已打开')
isConnected.value = true
toast.success('连接成功')
// 开始心跳
startHeartbeat()
})
// 2. 监听消息
socketTask.value.onMessage((res) => {
handleMessage(res.data as string)
})
// 3.1 监听连接关闭
socketTask.value.onClose(() => {
console.log('WebSocket 连接已关闭')
isConnected.value = false
socketTask.value = null
stopHeartbeat()
})
// 3.2 监听错误
socketTask.value.onError((err) => {
console.error('WebSocket 错误:', err)
isConnected.value = false
socketTask.value = null
stopHeartbeat()
toast.error('连接异常')
})
}
/** 关闭 WebSocket 连接 */
function disconnect() {
if (!socketTask.value) {
return
}
socketTask.value.close({
success: () => {
console.log('WebSocket 连接已主动关闭')
toast.success('已断开')
},
})
socketTask.value = null
isConnected.value = false
stopHeartbeat()
}
/** 切换连接状态 */
function toggleConnection() {
if (isConnected.value) {
disconnect()
} else {
connect()
}
}
// ======================= 心跳机制 =======================
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
/** 启动心跳机制 */
function startHeartbeat() {
stopHeartbeat()
// 30 秒发送一次心跳
heartbeatTimer = setInterval(() => {
if (socketTask.value && isConnected.value) {
socketTask.value.send({
data: 'ping',
fail: (err) => {
console.error('心跳发送失败:', err)
},
})
}
}, 30000)
}
/** 停止心跳机制 */
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
// ======================= 消息处理 =======================
/** 处理接收到的消息 */
function handleMessage(data: string) {
if (!data) {
return
}
try {
// 心跳响应
if (data === 'pong') {
return
}
// 1. 解析消息
const jsonMessage = JSON.parse(data)
const type = jsonMessage.type
const content = JSON.parse(jsonMessage.content)
if (!type) {
console.warn('未知的消息类型:', data)
return
}
// 2.1 处理 demo-message-receive 消息
if (type === 'demo-message-receive') {
const single = content.single
messageList.value.push({
text: content.text,
time: Date.now(),
type: single ? 'single' : 'group',
userId: content.fromUserId,
})
return
}
// 2.2 处理 notice-push 消息
if (type === 'notice-push') {
messageList.value.push({
text: content.title,
time: Date.now(),
type: 'system',
})
return
}
console.warn('未处理的消息类型:', type, data)
} catch (error) {
console.error('消息解析失败:', error, data)
}
}
/** 发送消息 */
function handleSend() {
if (!sendText.value.trim()) {
toast.show('请输入消息内容')
return
}
if (!socketTask.value || !isConnected.value) {
toast.show('请先建立连接')
return
}
// 1.1 构建消息内容
const messageContent = JSON.stringify({
text: sendText.value,
toUserId: sendUserId.value === 'all' ? undefined : sendUserId.value,
})
// 1.2 构建完整消息
const jsonMessage = JSON.stringify({
type: 'demo-message-send',
content: messageContent,
})
// 2. 发送消息
socketTask.value.send({
data: jsonMessage,
success: () => {
toast.success('发送成功')
sendText.value = ''
},
fail: (err) => {
console.error('消息发送失败:', err)
toast.error('发送失败')
},
})
}
/** 清空消息记录 */
function clearMessages() {
messageList.value = []
}
// ======================= 工具方法 =======================
/** 获取消息类型的徽标颜色 */
function getMessageBadgeColor(type?: string) {
switch (type) {
case 'group':
return '#07c160'
case 'single':
return '#1989fa'
case 'system':
return '#fa5151'
default:
return '#c8c9cc'
}
}
/** 获取消息类型的文本 */
function getMessageTypeText(type?: string) {
switch (type) {
case 'group':
return '群发'
case 'single':
return '单发'
case 'system':
return '系统'
default:
return '未知'
}
}
/** 处理用户选择变化 */
function handleUserChange({ value }: { value: string[] }) {
sendUserId.value = value[0] || 'all'
}
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
// ======================= 生命周期 =======================
/** 初始化 */
onMounted(async () => {
// 获取用户列表
try {
userList.value = await getSimpleUserList()
} catch (error) {
console.error('获取用户列表失败:', error)
}
})
/** 页面卸载 */
onUnmounted(() => {
// 页面销毁时断开连接
disconnect()
})
</script>
<style lang="scss" scoped>
.message-list {
&::-webkit-scrollbar {
display: none;
}
}
</style>