Pre Merge pull request !837 from 知了/feat/short-link
commit
e1458e198d
|
|
@ -0,0 +1,145 @@
|
|||
import request from '@/config/axios'
|
||||
|
||||
export interface ShortLinkVO {
|
||||
id?: number
|
||||
originalUrl: string
|
||||
shortCode?: string
|
||||
shortUrl?: string
|
||||
title?: string
|
||||
description?: string
|
||||
expireTime?: number
|
||||
status?: boolean
|
||||
forever?: boolean
|
||||
clickCount?: number
|
||||
createTime?: Date
|
||||
updateTime?: Date
|
||||
}
|
||||
|
||||
export interface ShortLinkPageReqVO extends PageParam {
|
||||
originalUrl?: string
|
||||
shortCode?: string
|
||||
title?: string
|
||||
status?: boolean
|
||||
createTime?: Date[]
|
||||
}
|
||||
|
||||
export interface ShortLinkStatisticsVO {
|
||||
id: number
|
||||
shortCode: string
|
||||
originalUrl: string
|
||||
title: string
|
||||
clickCount: number
|
||||
todayClickCount: number
|
||||
weekClickCount: number
|
||||
monthClickCount: number
|
||||
lastClickTime?: Date
|
||||
}
|
||||
|
||||
export interface ShortLinkClickLogVO {
|
||||
id: number
|
||||
shortCode: string
|
||||
clickTime: Date
|
||||
userIp: string
|
||||
userAgent: string
|
||||
referer?: string
|
||||
country?: string
|
||||
province?: string
|
||||
city?: string
|
||||
}
|
||||
|
||||
export interface ShortLinkClickLogPageReqVO extends PageParam {
|
||||
shortCode?: string
|
||||
userIp?: string
|
||||
clickTime?: Date[]
|
||||
}
|
||||
|
||||
// 查询短链列表
|
||||
export const getShortLinkPage = async (params: ShortLinkPageReqVO) => {
|
||||
return await request.get({ url: `/system/short-link/page`, params })
|
||||
}
|
||||
|
||||
// 查询短链详情
|
||||
export const getShortLink = async (id: number) => {
|
||||
return await request.get({ url: `/system/short-link/get?id=` + id })
|
||||
}
|
||||
|
||||
// 新增短链
|
||||
export const createShortLink = async (data: ShortLinkVO) => {
|
||||
return await request.post({ url: `/system/short-link/create`, data })
|
||||
}
|
||||
|
||||
// 修改短链
|
||||
export const updateShortLink = async (data: ShortLinkVO) => {
|
||||
return await request.put({ url: `/system/short-link/update`, data })
|
||||
}
|
||||
|
||||
// 删除短链
|
||||
export const deleteShortLink = async (id: number) => {
|
||||
return await request.delete({ url: `/system/short-link/delete?id=` + id })
|
||||
}
|
||||
|
||||
// 批量删除短链
|
||||
export const deleteShortLinkBatch = async (ids: number[]) => {
|
||||
return await request.delete({ url: `/system/short-link/delete-batch`, data: ids })
|
||||
}
|
||||
|
||||
// 更新短链状态
|
||||
export const updateShortLinkStatus = async (id: number, status: boolean) => {
|
||||
const v = status ? 1 : 0
|
||||
return await request.put({ url: `/system/short-link/update-status?id=` + id + `&status=` + v })
|
||||
}
|
||||
|
||||
// 导出短链 Excel
|
||||
export const exportShortLink = async (params: ShortLinkPageReqVO) => {
|
||||
return await request.download({ url: `/system/short-link/export-excel`, params })
|
||||
}
|
||||
|
||||
// 批量生成短链
|
||||
export const batchCreateShortLink = async (urls: string[]) => {
|
||||
return await request.post({ url: `/system/short-link/batch-create`, data: { urls } })
|
||||
}
|
||||
|
||||
// 获取短链访问日志分页
|
||||
export const getShortLinkAccessLogPage = async (params: ShortLinkClickLogPageReqVO) => {
|
||||
return await request.get({ url: `/system/short-link/access-log/page`, params })
|
||||
}
|
||||
|
||||
// 获取短链主域名
|
||||
export const getShortLinkDomain = async () => {
|
||||
return await request.get({ url: `/system/short-link/domain` })
|
||||
}
|
||||
|
||||
// 修改短链主域名
|
||||
export const updateShortLinkDomain = async (newDomain: string) => {
|
||||
return await request.put({ url: `/system/short-link/domain`, params: { newDomain } })
|
||||
}
|
||||
|
||||
// 获取短链概览统计
|
||||
export const getShortLinkOverviewStats = async (params: { startTime?: string; endTime?: string }) => {
|
||||
return await request.get({ url: `/system/short-link/statistics/overview`, params })
|
||||
}
|
||||
|
||||
// 获取短链趋势统计
|
||||
export const getShortLinkTrendStats = async (days: number) => {
|
||||
return await request.get({ url: `/system/short-link/statistics/trend`, params: { days } })
|
||||
}
|
||||
|
||||
// 获取短链状态分布统计
|
||||
export const getShortLinkStatusStats = async () => {
|
||||
return await request.get({ url: `/system/short-link/statistics/status` })
|
||||
}
|
||||
|
||||
// 获取短链地域访问统计
|
||||
export const getShortLinkRegionStats = async (params: { startTime?: string; endTime?: string }) => {
|
||||
return await request.get({ url: `/system/short-link/statistics/region`, params })
|
||||
}
|
||||
|
||||
// 获取短链设备类型统计
|
||||
export const getShortLinkDeviceStats = async (params: { startTime?: string; endTime?: string }) => {
|
||||
return await request.get({ url: `/system/short-link/statistics/device`, params })
|
||||
}
|
||||
|
||||
// 获取短链访问时段统计
|
||||
export const getShortLinkHourStats = async (date?: string) => {
|
||||
return await request.get({ url: `/system/short-link/statistics/hour`, params: { date } })
|
||||
}
|
||||
|
|
@ -50,6 +50,29 @@ const remainingRouter: AppRouteRecordRaw[] = [
|
|||
noTagsView: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
component: Layout,
|
||||
name: 'SystemHidden',
|
||||
meta: {
|
||||
hidden: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'shortlink/statistics',
|
||||
component: () => import('@/views/system/shortlink/statistics.vue'),
|
||||
name: 'SystemShortLinkStatistics',
|
||||
meta: {
|
||||
noCache: true,
|
||||
hidden: true,
|
||||
canTo: true,
|
||||
icon: 'ep:data-analysis',
|
||||
title: '短链统计',
|
||||
activeMenu: '/system/shortlink'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
<template>
|
||||
<Dialog v-model="dialogVisible" title="访问日志" width="1200px">
|
||||
<ContentWrap>
|
||||
<!-- 搜索表单 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="短链码" prop="shortCode">
|
||||
<el-input
|
||||
v-model="queryParams.shortCode"
|
||||
placeholder="请输入短链码"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="访问IP" prop="ip">
|
||||
<el-input
|
||||
v-model="queryParams.ip"
|
||||
placeholder="请输入访问IP"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="访问时间" prop="accessTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.accessTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="datetimerange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery">
|
||||
<Icon icon="ep:search" class="mr-5px" />
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetQuery">
|
||||
<Icon icon="ep:refresh" class="mr-5px" />
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column label="ID" align="center" prop="id" width="80" />
|
||||
<el-table-column label="短链码" align="center" prop="shortCode" width="120" />
|
||||
<el-table-column label="访问IP" align="center" prop="ip" width="140" />
|
||||
<el-table-column label="用户代理" align="center" prop="userAgent" show-overflow-tooltip />
|
||||
<el-table-column label="来源页面" align="center" prop="referer" show-overflow-tooltip />
|
||||
<el-table-column label="国家" align="center" prop="country" width="80" />
|
||||
<el-table-column label="省份" align="center" prop="province" width="80" />
|
||||
<el-table-column label="城市" align="center" prop="city" width="80" />
|
||||
<el-table-column
|
||||
label="访问时间"
|
||||
align="center"
|
||||
prop="accessTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import * as ShortLinkApi from '@/api/system/shortlink'
|
||||
|
||||
defineOptions({ name: 'AccessLogDialog' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
shortCode: undefined,
|
||||
ip: undefined,
|
||||
accessTime: undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (shortCode?: string) => {
|
||||
dialogVisible.value = true
|
||||
if (shortCode) {
|
||||
queryParams.shortCode = shortCode
|
||||
}
|
||||
await getList()
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ShortLinkApi.getShortLinkAccessLogPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
<template>
|
||||
<Dialog title="批量生成短链" v-model="dialogVisible" width="700px">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="批量链接" prop="urls">
|
||||
<el-input
|
||||
v-model="formData.urls"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="请输入要生成短链的URL,每行一个 例如: https://www.example1.com https://www.example2.com https://www.example3.com"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
每行输入一个URL,最多支持100个链接
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="parseUrls" type="info">解析链接</el-button>
|
||||
<span class="ml-2 text-sm text-gray-500">
|
||||
共解析到 {{ parsedUrls.length }} 个有效链接
|
||||
</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 解析结果预览 -->
|
||||
<div v-if="parsedUrls.length > 0" class="mt-4">
|
||||
<el-divider content-position="left">解析结果预览</el-divider>
|
||||
<el-table :data="parsedUrls" max-height="300" border>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column label="URL" prop="url" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.valid" type="success">有效</el-tag>
|
||||
<el-tag v-else type="danger">无效</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误信息" prop="error" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 生成结果 -->
|
||||
<div v-if="generateResults.length > 0" class="mt-4">
|
||||
<el-divider content-position="left">生成结果</el-divider>
|
||||
<el-table :data="generateResults" max-height="300" border>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column label="原始链接" prop="originalUrl" show-overflow-tooltip />
|
||||
<el-table-column label="短链接" prop="shortUrl" width="200">
|
||||
<template #default="scope">
|
||||
<el-link v-if="scope.row.success" type="primary" @click="copyShortUrl(scope.row.shortUrl)">
|
||||
{{ scope.row.shortUrl }}
|
||||
</el-link>
|
||||
<span v-else class="text-red-500">生成失败</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.success" type="success">成功</el-tag>
|
||||
<el-tag v-else type="danger">失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误信息" prop="error" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<el-button @click="exportResults" type="success">
|
||||
<Icon icon="ep:download" />导出结果
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button
|
||||
@click="handleGenerate"
|
||||
type="primary"
|
||||
:disabled="formLoading || parsedUrls.filter(u => u.valid).length === 0"
|
||||
>
|
||||
<Icon icon="ep:plus" />生成短链
|
||||
</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as ShortLinkApi from '@/api/system/shortlink'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { download } from '@/utils'
|
||||
|
||||
defineOptions({ name: 'BatchCreateDialog' })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const formData = ref({
|
||||
urls: ''
|
||||
})
|
||||
const formRules = reactive({
|
||||
urls: [
|
||||
{ required: true, message: '请输入要生成短链的URL', trigger: 'blur' }
|
||||
]
|
||||
})
|
||||
const formRef = ref()
|
||||
|
||||
const parsedUrls = ref<Array<{url: string, valid: boolean, error?: string}>>([])
|
||||
const generateResults = ref<Array<{
|
||||
originalUrl: string,
|
||||
shortUrl?: string,
|
||||
success: boolean,
|
||||
error?: string
|
||||
}>>([])
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = () => {
|
||||
dialogVisible.value = true
|
||||
resetForm()
|
||||
}
|
||||
defineExpose({ open })
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
urls: ''
|
||||
}
|
||||
parsedUrls.value = []
|
||||
generateResults.value = []
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
/** 解析URL */
|
||||
const parseUrls = () => {
|
||||
const urls = formData.value.urls.split('\n').filter(url => url.trim())
|
||||
|
||||
if (urls.length === 0) {
|
||||
ElMessage.warning('请输入URL')
|
||||
return
|
||||
}
|
||||
|
||||
if (urls.length > 100) {
|
||||
ElMessage.warning('最多支持100个链接')
|
||||
return
|
||||
}
|
||||
|
||||
parsedUrls.value = urls.map(url => {
|
||||
const trimmedUrl = url.trim()
|
||||
const valid = isValidUrl(trimmedUrl)
|
||||
return {
|
||||
url: trimmedUrl,
|
||||
valid,
|
||||
error: valid ? undefined : '无效的URL格式'
|
||||
}
|
||||
})
|
||||
|
||||
const validCount = parsedUrls.value.filter(u => u.valid).length
|
||||
ElMessage.success(`解析完成,共 ${validCount} 个有效链接`)
|
||||
}
|
||||
|
||||
/** 验证URL格式 */
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成短链 */
|
||||
const emit = defineEmits(['success'])
|
||||
const handleGenerate = async () => {
|
||||
const validUrls = parsedUrls.value.filter(u => u.valid).map(u => u.url)
|
||||
|
||||
if (validUrls.length === 0) {
|
||||
ElMessage.warning('没有有效的URL可以生成短链')
|
||||
return
|
||||
}
|
||||
|
||||
formLoading.value = true
|
||||
try {
|
||||
const results = await ShortLinkApi.batchCreateShortLink(validUrls)
|
||||
generateResults.value = results.map((result: any, index: number) => ({
|
||||
originalUrl: validUrls[index],
|
||||
shortUrl: result.success ? result.shortUrl : undefined,
|
||||
success: result.success,
|
||||
error: result.success ? undefined : result.error
|
||||
}))
|
||||
|
||||
const successCount = generateResults.value.filter(r => r.success).length
|
||||
ElMessage.success(`批量生成完成,成功 ${successCount} 个,失败 ${generateResults.value.length - successCount} 个`)
|
||||
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
ElMessage.error('批量生成失败')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制短链接 */
|
||||
const copyShortUrl = async (shortUrl: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shortUrl)
|
||||
ElMessage.success('短链接已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出结果 */
|
||||
const exportResults = () => {
|
||||
const csvContent = [
|
||||
['序号', '原始链接', '短链接', '状态', '错误信息'],
|
||||
...generateResults.value.map((result, index) => [
|
||||
index + 1,
|
||||
result.originalUrl,
|
||||
result.shortUrl || '',
|
||||
result.success ? '成功' : '失败',
|
||||
result.error || ''
|
||||
])
|
||||
].map(row => row.join(',')).join('\n')
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `批量生成短链结果_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<template>
|
||||
<el-dialog v-model="visible" title="修改主域名" width="500px">
|
||||
<el-form :model="form" ref="formRef" label-width="100px">
|
||||
<el-form-item label="主域名" prop="domain" :rules="[{ required: true, message: '请输入主域名' }]">
|
||||
<el-input v-model="form.domain" placeholder="例如:https://s.yourdomain.com" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="submit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as ShortLinkApi from '@/api/system/shortlink'
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const formRef = ref()
|
||||
const form = reactive({ domain: '' })
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true
|
||||
loading.value = true
|
||||
try {
|
||||
const domain = await ShortLinkApi.getShortLinkDomain()
|
||||
form.domain = domain
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await ShortLinkApi.updateShortLinkDomain(form.domain)
|
||||
ElMessage.success('主域名已更新')
|
||||
emit('success')
|
||||
visible.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<Dialog :title="dialogTitle" v-model="dialogVisible" width="600px">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="原始链接" prop="originalUrl">
|
||||
<el-input
|
||||
v-model="formData.originalUrl"
|
||||
placeholder="请输入原始链接"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自定义短码" prop="shortCode">
|
||||
<el-input
|
||||
v-model="formData.shortCode"
|
||||
placeholder="留空则自动生成"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
短码只能包含字母、数字、下划线和连字符,长度3-20位
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="formData.title"
|
||||
placeholder="请输入标题"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
placeholder="请输入描述"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="永久有效" prop="forever">
|
||||
<el-switch v-model="formData.forever" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!formData.forever" label="过期时间" prop="expireTime">
|
||||
<el-date-picker
|
||||
v-model="formData.expireTime"
|
||||
type="datetime"
|
||||
placeholder="选择过期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="x"
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="formData.status"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 状态布尔:true 开启,false 关闭
|
||||
import * as ShortLinkApi from '@/api/system/shortlink'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'ShortLinkForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
originalUrl: '',
|
||||
shortCode: '',
|
||||
title: '',
|
||||
description: '',
|
||||
forever: true,
|
||||
expireTime: undefined,
|
||||
status: true
|
||||
})
|
||||
const formRules = reactive({
|
||||
originalUrl: [
|
||||
{ required: true, message: '原始链接不能为空', trigger: 'blur' },
|
||||
{ type: 'url', message: '请输入正确的URL格式', trigger: 'blur' }
|
||||
],
|
||||
shortCode: [
|
||||
{ pattern: /^[a-zA-Z0-9_-]{3,20}$/, message: '短码只能包含字母、数字、下划线和连字符,长度3-20位', trigger: 'blur' }
|
||||
],
|
||||
title: [
|
||||
{ max: 100, message: '标题长度不能超过100个字符', trigger: 'blur' }
|
||||
],
|
||||
description: [
|
||||
{ max: 200, message: '描述长度不能超过200个字符', trigger: 'blur' }
|
||||
],
|
||||
expireTime: [
|
||||
{
|
||||
validator: (_rule: any, _value: any, callback: any) => {
|
||||
if (!formData.value.forever && !formData.value.expireTime) {
|
||||
callback(new Error('请选择过期时间'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '状态不能为空', trigger: 'change' }
|
||||
]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (type: string, id?: number) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = type === 'create' ? '添加短链' : '修改短链'
|
||||
formType.value = type
|
||||
resetForm()
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await ShortLinkApi.getShortLink(id)
|
||||
if (formData.value && (formData.value as any).expireTime) {
|
||||
const et = (formData.value as any).expireTime as any
|
||||
;(formData.value as any).expireTime = new Date(et).getTime()
|
||||
}
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
if (!formRef) return
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as ShortLinkApi.ShortLinkVO
|
||||
if (formType.value === 'create') {
|
||||
await ShortLinkApi.createShortLink(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await ShortLinkApi.updateShortLink(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
originalUrl: '',
|
||||
shortCode: '',
|
||||
title: '',
|
||||
description: '',
|
||||
expireTime: undefined,
|
||||
status: true
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
/** 检查短码可用性(本地格式校验) */
|
||||
const checkShortCode = async () => {}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
<template>
|
||||
<Dialog :title="`短链统计 - ${currentRow?.shortCode}`" v-model="dialogVisible" width="1000px">
|
||||
<div v-loading="loading">
|
||||
<!-- 统计概览 -->
|
||||
<el-row :gutter="20" class="mb-4">
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ statistics.clickCount || 0 }}</div>
|
||||
<div class="stat-label">总点击量</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ statistics.todayClickCount || 0 }}</div>
|
||||
<div class="stat-label">今日点击</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ statistics.weekClickCount || 0 }}</div>
|
||||
<div class="stat-label">本周点击</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ statistics.monthClickCount || 0 }}</div>
|
||||
<div class="stat-label">本月点击</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="20" class="mb-4">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>点击趋势</span>
|
||||
<el-select v-model="trendDays" @change="loadClickTrend" size="small" style="width: 100px">
|
||||
<el-option label="7天" :value="7" />
|
||||
<el-option label="15天" :value="15" />
|
||||
<el-option label="30天" :value="30" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="trendChartRef" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>地域分布</span>
|
||||
</template>
|
||||
<div ref="regionChartRef" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mb-4">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>设备统计</span>
|
||||
</template>
|
||||
<div ref="deviceChartRef" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>最近访问记录</span>
|
||||
</template>
|
||||
<el-table :data="recentLogs" max-height="300" size="small">
|
||||
<el-table-column label="访问时间" prop="clickTime" width="150">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.clickTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP地址" prop="userIp" width="120" />
|
||||
<el-table-column label="地区" width="100">
|
||||
<template #default="scope">
|
||||
{{ scope.row.province }}{{ scope.row.city }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" prop="referer" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 详细日志 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>访问日志</span>
|
||||
<el-button @click="exportLogs" type="primary" size="small">
|
||||
<Icon icon="ep:download" />导出日志
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="logList" v-loading="logLoading">
|
||||
<el-table-column label="访问时间" prop="clickTime" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.clickTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP地址" prop="userIp" width="140" />
|
||||
<el-table-column label="地区" width="150">
|
||||
<template #default="scope">
|
||||
{{ scope.row.country }} {{ scope.row.province }} {{ scope.row.city }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备信息" prop="userAgent" show-overflow-tooltip />
|
||||
<el-table-column label="来源页面" prop="referer" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<Pagination
|
||||
:total="logTotal"
|
||||
v-model:page="logQueryParams.pageNo"
|
||||
v-model:limit="logQueryParams.pageSize"
|
||||
@pagination="loadClickLogs"
|
||||
class="mt-4"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import * as ShortLinkApi from '@/api/system/shortlink'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
defineOptions({ name: 'StatisticsDialog' })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const loading = ref(false)
|
||||
const logLoading = ref(false)
|
||||
const currentRow = ref<any>(null)
|
||||
const trendDays = ref(7)
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref<any>({})
|
||||
const recentLogs = ref([])
|
||||
const logList = ref([])
|
||||
const logTotal = ref(0)
|
||||
const logQueryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
shortCode: ''
|
||||
})
|
||||
|
||||
// 图表引用
|
||||
const trendChartRef = ref()
|
||||
const regionChartRef = ref()
|
||||
const deviceChartRef = ref()
|
||||
|
||||
// 图表实例
|
||||
let trendChart: echarts.ECharts | null = null
|
||||
let regionChart: echarts.ECharts | null = null
|
||||
let deviceChart: echarts.ECharts | null = null
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (row: any) => {
|
||||
currentRow.value = row
|
||||
dialogVisible.value = true
|
||||
logQueryParams.shortCode = row.shortCode
|
||||
await loadStatistics()
|
||||
await loadClickLogs()
|
||||
|
||||
// 等待DOM渲染完成后初始化图表
|
||||
nextTick(() => {
|
||||
initCharts()
|
||||
loadClickTrend()
|
||||
loadRegionStats()
|
||||
loadDeviceStats()
|
||||
})
|
||||
}
|
||||
defineExpose({ open })
|
||||
|
||||
/** 加载统计数据 */
|
||||
const loadStatistics = async () => {
|
||||
if (!currentRow.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
statistics.value = await ShortLinkApi.getShortLinkStatistics(currentRow.value.id)
|
||||
|
||||
// 加载最近10条访问记录
|
||||
const recentLogsData = await ShortLinkApi.getShortLinkClickLogPage({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
shortCode: currentRow.value.shortCode
|
||||
})
|
||||
recentLogs.value = recentLogsData.list
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载点击日志 */
|
||||
const loadClickLogs = async () => {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const data = await ShortLinkApi.getShortLinkClickLogPage(logQueryParams)
|
||||
logList.value = data.list
|
||||
logTotal.value = data.total
|
||||
} finally {
|
||||
logLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化图表 */
|
||||
const initCharts = () => {
|
||||
if (trendChartRef.value) {
|
||||
trendChart = echarts.init(trendChartRef.value)
|
||||
}
|
||||
if (regionChartRef.value) {
|
||||
regionChart = echarts.init(regionChartRef.value)
|
||||
}
|
||||
if (deviceChartRef.value) {
|
||||
deviceChart = echarts.init(deviceChartRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载点击趋势 */
|
||||
const loadClickTrend = async () => {
|
||||
if (!currentRow.value || !trendChart) return
|
||||
|
||||
try {
|
||||
const data = await ShortLinkApi.getShortLinkClickTrend(currentRow.value.shortCode, trendDays.value)
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '点击趋势',
|
||||
textStyle: { fontSize: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((item: any) => item.date)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [{
|
||||
data: data.map((item: any) => item.count),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {}
|
||||
}]
|
||||
}
|
||||
|
||||
trendChart.setOption(option)
|
||||
} catch (error) {
|
||||
console.error('加载点击趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载地域统计 */
|
||||
const loadRegionStats = async () => {
|
||||
if (!currentRow.value || !regionChart) return
|
||||
|
||||
try {
|
||||
const data = await ShortLinkApi.getShortLinkRegionStats(currentRow.value.shortCode)
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '地域分布',
|
||||
textStyle: { fontSize: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: data.map((item: any) => ({
|
||||
value: item.count,
|
||||
name: item.region
|
||||
}))
|
||||
}]
|
||||
}
|
||||
|
||||
regionChart.setOption(option)
|
||||
} catch (error) {
|
||||
console.error('加载地域统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载设备统计 */
|
||||
const loadDeviceStats = async () => {
|
||||
if (!currentRow.value || !deviceChart) return
|
||||
|
||||
try {
|
||||
const data = await ShortLinkApi.getShortLinkDeviceStats(currentRow.value.shortCode)
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '设备统计',
|
||||
textStyle: { fontSize: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: data.map((item: any) => ({
|
||||
value: item.count,
|
||||
name: item.device
|
||||
}))
|
||||
}]
|
||||
}
|
||||
|
||||
deviceChart.setOption(option)
|
||||
} catch (error) {
|
||||
console.error('加载设备统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出日志 */
|
||||
const exportLogs = async () => {
|
||||
try {
|
||||
const allLogs = await ShortLinkApi.getShortLinkClickLogPage({
|
||||
pageNo: 1,
|
||||
pageSize: 10000,
|
||||
shortCode: currentRow.value.shortCode
|
||||
})
|
||||
|
||||
const csvContent = [
|
||||
['访问时间', 'IP地址', '国家', '省份', '城市', '设备信息', '来源页面'],
|
||||
...allLogs.list.map((log: any) => [
|
||||
formatDate(log.clickTime),
|
||||
log.userIp,
|
||||
log.country || '',
|
||||
log.province || '',
|
||||
log.city || '',
|
||||
log.userAgent,
|
||||
log.referer || ''
|
||||
])
|
||||
].map(row => row.join(',')).join('\n')
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `短链访问日志_${currentRow.value.shortCode}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
} catch (error) {
|
||||
console.error('导出日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听弹窗关闭,销毁图表 */
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (!visible) {
|
||||
trendChart?.dispose()
|
||||
regionChart?.dispose()
|
||||
deviceChart?.dispose()
|
||||
trendChart = null
|
||||
regionChart = null
|
||||
deviceChart = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="原始链接" prop="originalUrl">
|
||||
<el-input
|
||||
v-model="queryParams.originalUrl"
|
||||
placeholder="请输入原始链接"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="短链码" prop="shortCode">
|
||||
<el-input
|
||||
v-model="queryParams.shortCode"
|
||||
placeholder="请输入短链码"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="queryParams.title"
|
||||
placeholder="请输入标题"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option :label="'开启'" :value="true" />
|
||||
<el-option :label="'关闭'" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="datetimerange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['system:short-link:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" /> 新增
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
@click="handleExport"
|
||||
:loading="exportLoading"
|
||||
v-hasPermi="['system:short-link:export']"
|
||||
>
|
||||
<Icon icon="ep:download" />导出
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
plain
|
||||
@click="handleBatchCreate"
|
||||
v-hasPermi="['system:short-link:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" />批量生成
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
plain
|
||||
@click="goStatistics"
|
||||
v-hasPermi="['system:short-link:query']"
|
||||
>
|
||||
<Icon icon="ep:data-analysis" />统计
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
@click="handleUpdateDomain"
|
||||
v-hasPermi="['system:short-link:update']"
|
||||
>
|
||||
<Icon icon="ep:edit" />修改主域名
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange" stripe>
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column label="编号" align="center" prop="id" min-width="80" />
|
||||
<el-table-column label="短链码" align="center" prop="shortCode" min-width="120" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<el-link type="primary" @click="copyShortUrl(scope.row)">
|
||||
{{ scope.row.shortCode }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="短链接" align="center" prop="shortUrl" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<el-link type="primary" :href="scope.row.domain + '/' + scope.row.shortCode" target="_blank">
|
||||
{{ scope.row.domain + '/' + scope.row.shortCode }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原始链接" align="center" prop="originalUrl" min-width="250" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<el-link type="info" :href="scope.row.originalUrl" target="_blank">
|
||||
{{ scope.row.originalUrl }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标题" align="center" prop="title" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="描述" align="center" prop="description" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="点击次数" align="center" prop="clickCount" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag type="success">{{ scope.row.clickCount || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" prop="status" min-width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status ? 'success' : 'info'">{{ scope.row.status ? '开启' : '关闭' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" align="center" prop="expireTime" min-width="140" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.expireTime">{{ formatDate(scope.row.expireTime) }}</span>
|
||||
<el-tag v-else type="info">永久有效</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" min-width="240" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['system:short-link:update']"
|
||||
size="small"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="info"
|
||||
@click="handleAccessLog(scope.row)"
|
||||
v-hasPermi="['system:short-link:query']"
|
||||
size="small"
|
||||
>
|
||||
访问日志
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['system:short-link:delete']"
|
||||
size="small"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<ShortLinkForm ref="formRef" @success="getList" />
|
||||
|
||||
<!-- 批量生成弹窗 -->
|
||||
<BatchCreateDialog ref="batchCreateRef" @success="getList" />
|
||||
|
||||
|
||||
<!-- 访问日志弹窗 -->
|
||||
<AccessLogDialog ref="accessLogRef" />
|
||||
<DomainDialog ref="domainDialogRef" @success="onDomainUpdated" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dateFormatter, formatDate } from '@/utils/formatTime'
|
||||
// 状态过滤使用布尔值 true/false
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as ShortLinkApi from '@/api/system/shortlink'
|
||||
import ShortLinkForm from './ShortLinkForm.vue'
|
||||
import BatchCreateDialog from './BatchCreateDialog.vue'
|
||||
import AccessLogDialog from './AccessLogDialog.vue'
|
||||
import DomainDialog from './DomainDialog.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
defineOptions({ name: 'SystemShortLink' })
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref([]) // 列表的数据
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
originalUrl: undefined,
|
||||
shortCode: undefined,
|
||||
title: undefined,
|
||||
status: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const exportLoading = ref(false) // 导出的加载中
|
||||
const selectedRows = ref([]) // 选中的行
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { ...queryParams }
|
||||
if (typeof params.status === 'boolean') {
|
||||
params.status = params.status ? 1 : 0
|
||||
}
|
||||
const data = await ShortLinkApi.getShortLinkPage(params)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await ElMessageBox.confirm('是否确认删除短链编号为"' + id + '"的数据项?')
|
||||
// 发起删除
|
||||
await ShortLinkApi.deleteShortLink(id)
|
||||
ElMessage.success('删除成功')
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 导出按钮操作 */
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
// 导出的二次确认
|
||||
await ElMessageBox.confirm('是否确认导出所有短链数据项?')
|
||||
// 发起导出
|
||||
exportLoading.value = true
|
||||
const data = await ShortLinkApi.exportShortLink(queryParams)
|
||||
download.excel(data, '短链.xls')
|
||||
} catch {
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量生成操作 */
|
||||
const batchCreateRef = ref()
|
||||
const handleBatchCreate = () => {
|
||||
batchCreateRef.value.open()
|
||||
}
|
||||
|
||||
|
||||
/** 访问日志操作 */
|
||||
const accessLogRef = ref()
|
||||
const handleAccessLog = (row: any) => {
|
||||
accessLogRef.value.open(row.shortCode)
|
||||
}
|
||||
|
||||
const domainDialogRef = ref()
|
||||
const handleUpdateDomain = () => {
|
||||
domainDialogRef.value.open()
|
||||
}
|
||||
const onDomainUpdated = () => {
|
||||
ElMessage.success('主域名更新成功')
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const goStatistics = () => {
|
||||
router.push('/system/shortlink/statistics')
|
||||
}
|
||||
|
||||
/** 复制短链接 */
|
||||
const copyShortUrl = async (row: any) => {
|
||||
try {
|
||||
const shortUrl = row.domain + '/' + row.shortCode
|
||||
await navigator.clipboard.writeText(shortUrl)
|
||||
ElMessage.success('短链接已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择行操作 */
|
||||
const handleSelectionChange = (selection: any[]) => {
|
||||
selectedRows.value = selection
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
<template>
|
||||
<ContentWrap>
|
||||
<el-form :inline="true" label-width="100px">
|
||||
<el-form-item label="概览时间">
|
||||
<el-date-picker
|
||||
v-model="overviewRange"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
class="!w-360px"
|
||||
@change="loadOverview"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="趋势天数">
|
||||
<el-select v-model="trendDays" class="!w-180px" @change="loadTrend">
|
||||
<el-option :label="'7天'" :value="7" />
|
||||
<el-option :label="'14天'" :value="14" />
|
||||
<el-option :label="'30天'" :value="30" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="地域/设备">
|
||||
<el-date-picker
|
||||
v-model="range30"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
class="!w-360px"
|
||||
@change="() => { loadRegion(); loadDevice(); }"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="访问日期">
|
||||
<el-date-picker
|
||||
v-model="hourDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
class="!w-200px"
|
||||
@change="loadHour"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="refreshAll"><Icon icon="ep:refresh" />刷新全部</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<ContentWrap>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-title">总短链数</div>
|
||||
<div class="stat-value">{{ overview.totalLinks || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-title">总点击量</div>
|
||||
<div class="stat-value">{{ overview.totalClicks || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-title">活跃短链</div>
|
||||
<div class="stat-value">{{ overview.activeLinks || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-title">平均点击率</div>
|
||||
<div class="stat-value">{{ (overview.avgClickRate || 0).toFixed(2) }}%</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-title">区间新增</div>
|
||||
<div class="stat-value">{{ overview.todayNewLinks || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-title">区间点击</div>
|
||||
<div class="stat-value">{{ overview.todayClicks || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</ContentWrap>
|
||||
|
||||
<ContentWrap>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div>短链趋势</div>
|
||||
</template>
|
||||
<Echart :options="trendOption" :height="420" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div>状态分布</div>
|
||||
</template>
|
||||
<Echart :options="statusOption" :height="420" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" class="card-box">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div>地域访问 Top10</div>
|
||||
</template>
|
||||
<Echart :options="regionOption" :height="420" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" class="card-box">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div>设备类型分布</div>
|
||||
</template>
|
||||
<Echart :options="deviceOption" :height="420" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="24" class="card-box">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div>访问时段</div>
|
||||
</template>
|
||||
<Echart :options="hourOption" :height="420" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Echart from '@/components/Echart/src/Echart.vue'
|
||||
import {
|
||||
getShortLinkOverviewStats,
|
||||
getShortLinkTrendStats,
|
||||
getShortLinkStatusStats,
|
||||
getShortLinkRegionStats,
|
||||
getShortLinkDeviceStats,
|
||||
getShortLinkHourStats
|
||||
} from '@/api/system/shortlink/index'
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
|
||||
defineOptions({ name: 'SystemShortLinkStatistics' })
|
||||
|
||||
const overviewRange = ref<string[]>()
|
||||
const range30 = ref<string[]>()
|
||||
const trendDays = ref(7)
|
||||
const hourDate = ref<string>(formatDate(new Date(), 'YYYY-MM-DD'))
|
||||
|
||||
const overview = reactive({
|
||||
totalLinks: 0,
|
||||
totalClicks: 0,
|
||||
activeLinks: 0,
|
||||
avgClickRate: 0,
|
||||
todayNewLinks: 0,
|
||||
todayClicks: 0
|
||||
})
|
||||
|
||||
const trendOption = reactive({
|
||||
legend: { data: ['新增短链数', '点击量'] },
|
||||
tooltip: {},
|
||||
xAxis: { type: 'category', data: [] as any[] },
|
||||
yAxis: { minInterval: 1 },
|
||||
series: [
|
||||
{ name: '新增短链数', type: 'line' as const, smooth: true, label: { show: true }, data: [] as any[] },
|
||||
{ name: '点击量', type: 'line' as const, smooth: true, label: { show: true }, data: [] as any[] }
|
||||
]
|
||||
})
|
||||
|
||||
const statusOption = reactive({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { top: 'bottom' },
|
||||
series: [
|
||||
{
|
||||
name: '状态分布',
|
||||
type: 'pie' as const,
|
||||
radius: '60%',
|
||||
label: { formatter: '{b}: {c} ({d}%)' },
|
||||
data: [] as any[]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const regionOption = reactive({
|
||||
tooltip: {},
|
||||
xAxis: { type: 'category', data: [] as any[] },
|
||||
yAxis: { minInterval: 1 },
|
||||
series: [
|
||||
{ name: '访问量', type: 'bar' as const, label: { show: true }, data: [] as any[] }
|
||||
]
|
||||
})
|
||||
|
||||
const deviceOption = reactive({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { top: 'bottom' },
|
||||
series: [
|
||||
{ name: '设备类型', type: 'pie' as const, radius: '60%', label: { formatter: '{b}: {c} ({d}%)' }, data: [] as any[] }
|
||||
]
|
||||
})
|
||||
|
||||
const hourOption = reactive({
|
||||
tooltip: {},
|
||||
xAxis: { type: 'category', data: [] as any[] },
|
||||
yAxis: { minInterval: 1 },
|
||||
series: [
|
||||
{ name: '访问量', type: 'bar' as const, label: { show: true }, data: [] as any[] }
|
||||
]
|
||||
})
|
||||
|
||||
const loadOverview = async () => {
|
||||
const params: any = {}
|
||||
if (overviewRange?.value && overviewRange.value.length === 2) {
|
||||
params.startTime = overviewRange.value[0]
|
||||
params.endTime = overviewRange.value[1]
|
||||
}
|
||||
const data = await getShortLinkOverviewStats(params)
|
||||
overview.totalLinks = data.totalLinks || 0
|
||||
overview.totalClicks = data.totalClicks || 0
|
||||
overview.activeLinks = data.activeLinks || 0
|
||||
overview.avgClickRate = data.avgClickRate || 0
|
||||
overview.todayNewLinks = data.todayNewLinks || 0
|
||||
overview.todayClicks = data.todayClicks || 0
|
||||
}
|
||||
|
||||
const loadTrend = async () => {
|
||||
const list = await getShortLinkTrendStats(trendDays.value)
|
||||
const trend = Array.isArray(list) && list.length > 0 ? list[0] : { trendData: [] }
|
||||
const x: string[] = []
|
||||
const createArr: number[] = []
|
||||
const clickArr: number[] = []
|
||||
;(trend.trendData || []).forEach((item: any) => {
|
||||
x.push(formatDate(new Date(item.date), 'YYYY-MM-DD'))
|
||||
createArr.push(item.createCount || 0)
|
||||
clickArr.push(item.clickCount || 0)
|
||||
})
|
||||
trendOption.xAxis.data = x
|
||||
trendOption.series[0].data = createArr
|
||||
trendOption.series[1].data = clickArr
|
||||
}
|
||||
|
||||
const loadStatus = async () => {
|
||||
const data = await getShortLinkStatusStats()
|
||||
const arr = (data.statusData || []).map((s: any) => ({ name: s.statusName, value: s.count }))
|
||||
statusOption.series[0].data = arr
|
||||
}
|
||||
|
||||
const loadRegion = async () => {
|
||||
const params: any = {}
|
||||
if (range30?.value && range30.value.length === 2) {
|
||||
params.startTime = range30.value[0]
|
||||
params.endTime = range30.value[1]
|
||||
}
|
||||
const data = await getShortLinkRegionStats(params)
|
||||
const list = (data.regionData || []).slice().sort((a: any, b: any) => (b.visitCount || 0) - (a.visitCount || 0)).slice(0, 10)
|
||||
regionOption.xAxis.data = list.map((i: any) => `${i.province || ''}-${i.city || ''}`)
|
||||
regionOption.series[0].data = list.map((i: any) => i.visitCount || 0)
|
||||
}
|
||||
|
||||
const loadDevice = async () => {
|
||||
const params: any = {}
|
||||
if (range30?.value && range30.value.length === 2) {
|
||||
params.startTime = range30.value[0]
|
||||
params.endTime = range30.value[1]
|
||||
}
|
||||
const data = await getShortLinkDeviceStats(params)
|
||||
deviceOption.series[0].data = (data.deviceData || []).map((d: any) => ({ name: d.deviceName, value: d.visitCount || 0 }))
|
||||
}
|
||||
|
||||
const loadHour = async () => {
|
||||
const data = await getShortLinkHourStats(hourDate.value)
|
||||
const x = (data.hourData || []).map((h: any) => h.hourRange)
|
||||
const v = (data.hourData || []).map((h: any) => h.visitCount || 0)
|
||||
hourOption.xAxis.data = x
|
||||
hourOption.series[0].data = v
|
||||
}
|
||||
|
||||
const refreshAll = () => {
|
||||
loadOverview()
|
||||
loadTrend()
|
||||
loadStatus()
|
||||
loadRegion()
|
||||
loadDevice()
|
||||
loadHour()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const now = new Date()
|
||||
const start30 = new Date(now.getTime() - 30 * 24 * 3600 * 1000)
|
||||
const startToday = new Date()
|
||||
startToday.setHours(0, 0, 0, 0)
|
||||
overviewRange.value = [
|
||||
formatDate(startToday, 'YYYY-MM-DD HH:mm:ss'),
|
||||
formatDate(now, 'YYYY-MM-DD HH:mm:ss')
|
||||
]
|
||||
range30.value = [
|
||||
formatDate(start30, 'YYYY-MM-DD HH:mm:ss'),
|
||||
formatDate(now, 'YYYY-MM-DD HH:mm:ss')
|
||||
]
|
||||
refreshAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-title {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue