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
|
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: '/',
|
path: '/',
|
||||||
component: Layout,
|
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