Pre Merge pull request !837 from 知了/feat/short-link

pull/837/MERGE
知了 2025-12-13 02:34:25 +00:00 committed by Gitee
commit e1458e198d
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
9 changed files with 1853 additions and 0 deletions

View File

@ -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 } })
}

View File

@ -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,

View File

@ -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>

View File

@ -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每行一个&#10;例如:&#10;https://www.example1.com&#10;https://www.example2.com&#10;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>

View File

@ -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>

View File

@ -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) // 12
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>