Compare commits
5 Commits
735d913bf4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cbe5851a00 | |||
| 38db5cbc07 | |||
| 0053d6c3e0 | |||
| 570406309e | |||
| 1ba0e6c40b |
@@ -15,11 +15,18 @@ export const uploadBlogImageApi = (md5: string, form: FormData): Promise<string>
|
||||
data: form
|
||||
})
|
||||
|
||||
export const queryBlogApi = (currentPage: number, pageSize: number): Promise<IPageRecord<IBlog>> =>
|
||||
export const queryBlogApi = (currentPage: number, pageSize: number,
|
||||
category: string): Promise<IPageRecord<IBlog>> =>
|
||||
cloudService({
|
||||
url: '/blog-api/blog/all/page',
|
||||
url: '/blog-api/blog/condition/page',
|
||||
method: 'get',
|
||||
params: { currentPage, pageSize }
|
||||
params: { currentPage, pageSize, category }
|
||||
})
|
||||
|
||||
export const queryUnapprovedBlogApi = (): Promise<IBlog[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/blog/unapproved',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
export const queryBlogByIdApi = (id: number): Promise<IBlog> =>
|
||||
@@ -86,3 +93,11 @@ export const queryBlogVisitApi = (currentPage: number,
|
||||
method: 'get',
|
||||
params: { currentPage, pageSize }
|
||||
})
|
||||
|
||||
|
||||
export const queryBlogAlterApi = (name: string): Promise<any> =>
|
||||
cloudService({
|
||||
url: '/alter-api/log-alter/name',
|
||||
method: 'get',
|
||||
params: { name }
|
||||
})
|
||||
|
||||
54
src/apis/stats.ts
Normal file
54
src/apis/stats.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { cloudService } from './index'
|
||||
|
||||
import { IStatsChart } from 'vue3-common/types'
|
||||
import { IBlogOverview } from '@/types/blog'
|
||||
|
||||
export const queryOverviewStatsApi = (): Promise<IBlogOverview> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/overview',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
|
||||
export const queryCategoryStatsApi = (): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/category',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
|
||||
export const queryApprovedMonthlyStatsApi = (year: number): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/approved/monthly',
|
||||
method: 'get',
|
||||
params: { year }
|
||||
})
|
||||
|
||||
|
||||
export const queryVisitMonthlyStatsApi = (year: number): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/visit/monthly',
|
||||
method: 'get',
|
||||
params: { year }
|
||||
})
|
||||
|
||||
|
||||
export const queryVisitRankStatsApi = (): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/visit/rank',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
|
||||
export const queryReadRankStatsApi = (): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/read/rank',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
|
||||
export const queryCommentRankStatsApi = (): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/comment/rank',
|
||||
method: 'get'
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1728286912539" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9408" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M960 512c0-59.5-40.9-109.7-96.1-124v68.6c-9.4-5.5-20.4-8.6-32-8.6-35.3 0-64.1 28.7-64.1 64s28.7 64 64.1 64c11.7 0 22.6-3.1 32-8.6V636c-10.2 2.6-21 4.1-32 4.1-70.7 0-128.1-57.4-128.1-128s57.5-128 128.1-128c11.1 0 21.8 1.4 32 4V192c0-52.9-41.8-96-93.1-96H221.2c-51.3 0-93.1 43.1-93.1 96v159.5h128.1v64H128.1V352H64v64h64.1v191.5h128.1v64H127.1V608H64v64h64.1v160c0 52.9 41.8 96 93.1 96h549.6c51.3 0 93.1-43.1 93.1-96V635.9C919.1 621.7 960 571.5 960 512zM320.3 288h384.4v64H320.3v-64z m0 192h318.4v64H320.3v-64z m384.4 256H320.3v-64h384.4v64z m164.4-276z m4.9 3.9l-0.1-0.1s0.1 0 0.1 0.1zM869.1 564z m4.9-3.9l-0.1 0.1s0.1 0 0.1-0.1z m21.9-45.2c0-1 0.1-1.9 0.1-2.9s0-2-0.1-2.9c0 1 0.1 1.9 0.1 2.9s-0.1 2-0.1 2.9z m-17.7 41.2l0.3-0.3-0.3 0.3z m1.6-1.7l0.1-0.1-0.1 0.1z m2.3-2.8c0.2-0.2 0.3-0.4 0.5-0.6-0.2 0.2-0.3 0.4-0.5 0.6z m1.5-1.9c0.2-0.2 0.4-0.5 0.5-0.7-0.2 0.2-0.3 0.4-0.5 0.7z m2-2.9c0.2-0.3 0.4-0.6 0.6-1-0.2 0.4-0.4 0.7-0.6 1z m1.3-2.2c0.3-0.4 0.5-0.9 0.8-1.4-0.2 0.5-0.5 1-0.8 1.4z m1.7-2.9c0.2-0.5 0.5-0.9 0.7-1.4-0.2 0.5-0.5 0.9-0.7 1.4z m1.2-2.5c0.3-0.6 0.6-1.3 0.9-1.9-0.3 0.7-0.6 1.3-0.9 1.9z m1.3-2.9c0.2-0.6 0.5-1.2 0.7-1.8-0.2 0.6-0.4 1.2-0.7 1.8z m1.1-2.8c0.3-0.8 0.5-1.6 0.8-2.4-0.3 0.9-0.5 1.7-0.8 2.4z m1-2.9c0.2-0.7 0.4-1.5 0.6-2.2-0.2 0.8-0.4 1.5-0.6 2.2z m0.8-3c0.2-0.9 0.4-1.8 0.6-2.6-0.2 0.8-0.4 1.7-0.6 2.6z m0.7-2.9c0.2-0.8 0.3-1.7 0.5-2.5-0.2 0.8-0.4 1.6-0.5 2.5z m0.5-3.3c0.1-0.9 0.3-1.9 0.4-2.8-0.1 0.9-0.2 1.8-0.4 2.8z m0.4-3c0.1-0.9 0.2-1.9 0.2-2.8 0 1-0.1 1.9-0.2 2.8z m-17.4-50.5l0.3 0.3-0.3-0.3z m1.6 1.7l0.1 0.1-0.1-0.1z m2.3 2.8c0.2 0.2 0.3 0.4 0.5 0.6-0.2-0.2-0.3-0.4-0.5-0.6z m1.5 1.9c0.2 0.2 0.4 0.5 0.5 0.7-0.2-0.2-0.3-0.4-0.5-0.7z m2 2.9c0.2 0.3 0.4 0.7 0.6 1-0.2-0.4-0.4-0.7-0.6-1z m1.3 2.2c0.3 0.4 0.5 0.9 0.8 1.4-0.2-0.5-0.5-1-0.8-1.4z m1.7 2.9c0.2 0.5 0.5 0.9 0.7 1.4-0.2-0.5-0.5-0.9-0.7-1.4z m1.2 2.5c0.3 0.6 0.6 1.3 0.9 1.9-0.3-0.7-0.6-1.3-0.9-1.9z m1.3 2.9c0.2 0.6 0.5 1.2 0.7 1.8-0.2-0.6-0.4-1.2-0.7-1.8z m1.1 2.8c0.3 0.8 0.5 1.6 0.8 2.4-0.3-0.9-0.5-1.7-0.8-2.4z m1 2.9c0.2 0.7 0.4 1.5 0.6 2.2-0.2-0.8-0.4-1.5-0.6-2.2z m0.8 3c0.2 0.9 0.4 1.8 0.6 2.6-0.2-0.8-0.4-1.7-0.6-2.6z m0.7 2.9c0.2 0.8 0.3 1.7 0.5 2.5-0.2-0.8-0.4-1.6-0.5-2.5z m0.5 3.3c0.1 0.9 0.3 1.9 0.4 2.8-0.1-0.9-0.2-1.8-0.4-2.8z m0.4 3c0.1 0.9 0.2 1.9 0.2 2.8 0-1-0.1-1.9-0.2-2.8z" fill="#47444F" p-id="9409"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1728286912539" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9408" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M960 512c0-59.5-40.9-109.7-96.1-124v68.6c-9.4-5.5-20.4-8.6-32-8.6-35.3 0-64.1 28.7-64.1 64s28.7 64 64.1 64c11.7 0 22.6-3.1 32-8.6V636c-10.2 2.6-21 4.1-32 4.1-70.7 0-128.1-57.4-128.1-128s57.5-128 128.1-128c11.1 0 21.8 1.4 32 4V192c0-52.9-41.8-96-93.1-96H221.2c-51.3 0-93.1 43.1-93.1 96v159.5h128.1v64H128.1V352H64v64h64.1v191.5h128.1v64H127.1V608H64v64h64.1v160c0 52.9 41.8 96 93.1 96h549.6c51.3 0 93.1-43.1 93.1-96V635.9C919.1 621.7 960 571.5 960 512zM320.3 288h384.4v64H320.3v-64z m0 192h318.4v64H320.3v-64z m384.4 256H320.3v-64h384.4v64z m164.4-276z m4.9 3.9l-0.1-0.1s0.1 0 0.1 0.1zM869.1 564z m4.9-3.9l-0.1 0.1s0.1 0 0.1-0.1z m21.9-45.2c0-1 0.1-1.9 0.1-2.9s0-2-0.1-2.9c0 1 0.1 1.9 0.1 2.9s-0.1 2-0.1 2.9z m-17.7 41.2l0.3-0.3-0.3 0.3z m1.6-1.7l0.1-0.1-0.1 0.1z m2.3-2.8c0.2-0.2 0.3-0.4 0.5-0.6-0.2 0.2-0.3 0.4-0.5 0.6z m1.5-1.9c0.2-0.2 0.4-0.5 0.5-0.7-0.2 0.2-0.3 0.4-0.5 0.7z m2-2.9c0.2-0.3 0.4-0.6 0.6-1-0.2 0.4-0.4 0.7-0.6 1z m1.3-2.2c0.3-0.4 0.5-0.9 0.8-1.4-0.2 0.5-0.5 1-0.8 1.4z m1.7-2.9c0.2-0.5 0.5-0.9 0.7-1.4-0.2 0.5-0.5 0.9-0.7 1.4z m1.2-2.5c0.3-0.6 0.6-1.3 0.9-1.9-0.3 0.7-0.6 1.3-0.9 1.9z m1.3-2.9c0.2-0.6 0.5-1.2 0.7-1.8-0.2 0.6-0.4 1.2-0.7 1.8z m1.1-2.8c0.3-0.8 0.5-1.6 0.8-2.4-0.3 0.9-0.5 1.7-0.8 2.4z m1-2.9c0.2-0.7 0.4-1.5 0.6-2.2-0.2 0.8-0.4 1.5-0.6 2.2z m0.8-3c0.2-0.9 0.4-1.8 0.6-2.6-0.2 0.8-0.4 1.7-0.6 2.6z m0.7-2.9c0.2-0.8 0.3-1.7 0.5-2.5-0.2 0.8-0.4 1.6-0.5 2.5z m0.5-3.3c0.1-0.9 0.3-1.9 0.4-2.8-0.1 0.9-0.2 1.8-0.4 2.8z m0.4-3c0.1-0.9 0.2-1.9 0.2-2.8 0 1-0.1 1.9-0.2 2.8z m-17.4-50.5l0.3 0.3-0.3-0.3z m1.6 1.7l0.1 0.1-0.1-0.1z m2.3 2.8c0.2 0.2 0.3 0.4 0.5 0.6-0.2-0.2-0.3-0.4-0.5-0.6z m1.5 1.9c0.2 0.2 0.4 0.5 0.5 0.7-0.2-0.2-0.3-0.4-0.5-0.7z m2 2.9c0.2 0.3 0.4 0.7 0.6 1-0.2-0.4-0.4-0.7-0.6-1z m1.3 2.2c0.3 0.4 0.5 0.9 0.8 1.4-0.2-0.5-0.5-1-0.8-1.4z m1.7 2.9c0.2 0.5 0.5 0.9 0.7 1.4-0.2-0.5-0.5-0.9-0.7-1.4z m1.2 2.5c0.3 0.6 0.6 1.3 0.9 1.9-0.3-0.7-0.6-1.3-0.9-1.9z m1.3 2.9c0.2 0.6 0.5 1.2 0.7 1.8-0.2-0.6-0.4-1.2-0.7-1.8z m1.1 2.8c0.3 0.8 0.5 1.6 0.8 2.4-0.3-0.9-0.5-1.7-0.8-2.4z m1 2.9c0.2 0.7 0.4 1.5 0.6 2.2-0.2-0.8-0.4-1.5-0.6-2.2z m0.8 3c0.2 0.9 0.4 1.8 0.6 2.6-0.2-0.8-0.4-1.7-0.6-2.6z m0.7 2.9c0.2 0.8 0.3 1.7 0.5 2.5-0.2-0.8-0.4-1.6-0.5-2.5z m0.5 3.3c0.1 0.9 0.3 1.9 0.4 2.8-0.1-0.9-0.2-1.8-0.4-2.8z m0.4 3c0.1 0.9 0.2 1.9 0.2 2.8 0-1-0.1-1.9-0.2-2.8z" fill="" p-id="9409"></path></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
1
src/icons/svg/admin/blogMonitor.svg
Normal file
1
src/icons/svg/admin/blogMonitor.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1761638922713" class="icon" viewBox="0 0 1080 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7439" xmlns:xlink="http://www.w3.org/1999/xlink" width="210.9375" height="200"><path d="M941.34455136 32H93.25188261A82.58521594 82.58521594 0 0 0 10.66666667 114.58521594v603.15623906a82.58521594 82.58521594 0 0 0 82.58521594 82.58521594H941.34455136a82.58521594 82.58521594 0 0 0 82.58521687-82.58521594V114.58521594A82.58521594 82.58521594 0 0 0 941.34455136 32zM533.05004417 684.347135c-139.89814781 0-253.31577562-111.66120937-253.31577562-249.47850469s113.41762687-249.47850469 253.31577562-249.47850562 253.31577562 111.69477094 253.31577562 249.47850562-113.40643969 249.47850469-253.31577562 249.47850469z" p-id="7440"></path><path d="M457.72543573 283.66004375a75.83922844 75.83922844 0 1 1-75.82804125 75.8392275 75.82804031 75.82804031 0 0 1 75.82804125-75.8392275zM157.24367886 913.68836187h712.63590937c18.88429219 0 34.19981156 13.72691156 34.19981063 30.65341219v17.00481282c0 16.92650156-15.31551937 30.65341312-34.19981063 30.65341312h-712.63590937c-18.88429219 0-34.19981156-13.72691156-34.19981156-30.65341313v-17.02718718c0-16.92650156 15.30433125-30.63103781 34.19981156-30.63103782z" p-id="7441"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/icons/svg/admin/blogUnapproved.svg
Normal file
1
src/icons/svg/admin/blogUnapproved.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1761638996401" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9280" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M960 1024H69.487A69.487 69.487 0 0 1 0 954.513V577.5a5.487 5.487 0 0 1 0.738-2.75l126.525-219.175a5.487 5.487 0 0 0 0.737-2.75V69.488A69.487 69.487 0 0 1 197.5 0h504.225a5.487 5.487 0 0 1 3.888 1.613l188.75 188.75a5.487 5.487 0 0 1 1.612 3.887v188.088a5.487 5.487 0 0 0 0.925 3.05l126.175 189.225a5.487 5.487 0 0 1 0.925 3.05V960a64 64 0 0 1-64 64zM117.975 496.987l-48.112 70.425A5.487 5.487 0 0 0 74.4 576h48.1a5.487 5.487 0 0 0 5.487-5.488v-70.437a5.487 5.487 0 0 0-10.012-3.088zM704 122.5a69.5 69.5 0 0 0 69.5 69.5 5.487 5.487 0 0 0 3.888-9.375l-64.013-64A5.487 5.487 0 0 0 704 122.5zM826.5 256H709.487A69.487 69.487 0 0 1 640 186.512V69.487A5.487 5.487 0 0 0 634.513 64H261.487A69.487 69.487 0 0 0 192 133.487v437.025a5.487 5.487 0 0 0 5.5 5.488h117.1a5.475 5.475 0 0 1 5.475 5.325 192 192 0 0 0 383.85 0A5.475 5.475 0 0 1 709.4 576h117.1a5.487 5.487 0 0 0 5.488-5.487V261.487a5.487 5.487 0 0 0-5.475-5.487zM896 525.262v45.25a5.487 5.487 0 0 0 5.487 5.488h45.25a5.487 5.487 0 0 0 3.888-9.375l-45.25-45.25a5.487 5.487 0 0 0-9.375 3.888z m-570.513-141.25h373.025A5.487 5.487 0 0 1 704 389.5v53a5.487 5.487 0 0 1-5.488 5.487H325.488A5.487 5.487 0 0 1 320 442.5v-53.013a5.487 5.487 0 0 1 5.487-5.487z m0-128h245a5.487 5.487 0 0 1 5.488 5.488v53.013a5.487 5.487 0 0 1-5.488 5.487h-245a5.487 5.487 0 0 1-5.487-5.487v-53.025a5.487 5.487 0 0 1 5.487-5.488z m-5.487 0" p-id="9281"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,12 +1,21 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
IAlterLog,
|
||||
IBlog, IBlogCategory, IBlogComment,
|
||||
IBlogLatest, IBlogNestedComment, IBlogStats, INewBlog, IQueryBlog
|
||||
} from '@/types/blog'
|
||||
import {
|
||||
addBlogApi, addBlogCommentApi, deleteBlogApi,
|
||||
queryBlogApi, queryBlogByIdApi,
|
||||
queryBlogCategoryApi, queryBlogCommentApi, queryBlogStatsApi, queryLatestBlogApi, updateBlogApi
|
||||
addBlogApi,
|
||||
addBlogCommentApi,
|
||||
deleteBlogApi, queryBlogAlterApi,
|
||||
queryBlogApi,
|
||||
queryBlogByIdApi,
|
||||
queryBlogCategoryApi,
|
||||
queryBlogCommentApi,
|
||||
queryBlogStatsApi,
|
||||
queryLatestBlogApi,
|
||||
queryUnapprovedBlogApi,
|
||||
updateBlogApi
|
||||
} from '@/apis/blog.ts'
|
||||
import { IHandleApi } from 'vue3-common/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -16,8 +25,10 @@ import { flattenToTwoLevel } from '@/utils/blog/blogUtil.ts'
|
||||
export const useBlogStore = defineStore('blog', {
|
||||
state: () => ({
|
||||
blogList: [] as IBlog[],
|
||||
unapprovedBlogList: [] as IBlog[],
|
||||
blogCommentList: [] as IBlogComment[],
|
||||
blogNestedCommentList: [] as IBlogNestedComment[],
|
||||
alterLogList: [] as IAlterLog[],
|
||||
currentBlogComment: {
|
||||
id: 0,
|
||||
blogId: 0,
|
||||
@@ -57,7 +68,9 @@ export const useBlogStore = defineStore('blog', {
|
||||
blogCategoryList: [] as IBlogCategory[],
|
||||
blogStats: {} as IBlogStats,
|
||||
latestBlogList: [] as IBlogLatest[],
|
||||
queryBlog: {} as IQueryBlog,
|
||||
queryBlog: {
|
||||
category: ''
|
||||
} as IQueryBlog,
|
||||
blogImagePath: ImagePathEnum.BLOG,
|
||||
blogApiType: 'ADD' as IHandleApi,
|
||||
pageInfo: {
|
||||
@@ -66,15 +79,22 @@ export const useBlogStore = defineStore('blog', {
|
||||
totalSize: 1
|
||||
},
|
||||
isScrollEnd: false,
|
||||
progressValue: '0%'
|
||||
progressValue: '0%',
|
||||
isRefresh: false
|
||||
}),
|
||||
actions: {
|
||||
async queryBlogList() {
|
||||
const { currentPage, pageSize } = this.pageInfo
|
||||
const result = await queryBlogApi(currentPage, pageSize)
|
||||
const result = await queryBlogApi(currentPage, pageSize, this.queryBlog.category)
|
||||
this.blogList = result.records
|
||||
this.pageInfo.totalSize = result.total
|
||||
},
|
||||
async queryUnapprovedBlogList() {
|
||||
this.unapprovedBlogList = await queryUnapprovedBlogApi()
|
||||
},
|
||||
async queryBlogAlterLogList() {
|
||||
this.alterLogList = await queryBlogAlterApi('puppy-service')
|
||||
},
|
||||
async queryBlogById(id: number) {
|
||||
this.currentBlog = await queryBlogByIdApi(id)
|
||||
await this.queryBlogComment(this.currentBlogId)
|
||||
|
||||
@@ -194,9 +194,9 @@ $transition-time: 0.28s;
|
||||
|
||||
.card-item {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #FFFFFF;
|
||||
border: 1px solid #CFCFCF;
|
||||
border-radius: 10px;
|
||||
box-shadow: 5px 5px 5px #CFCFCF;
|
||||
// box-shadow: 1px 1px 2px #CFCFCF;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,3 +84,20 @@ export interface IBlogNestedComment {
|
||||
comment: IBlogComment;
|
||||
replies: IBlogComment[];
|
||||
}
|
||||
|
||||
export interface IBlogOverview {
|
||||
blogCount: number;
|
||||
categoryCount: number;
|
||||
wordCount: number;
|
||||
greatCount: number;
|
||||
visitCount: number;
|
||||
}
|
||||
|
||||
export interface IAlterLog {
|
||||
alterName: string;
|
||||
alterTime: string;
|
||||
logTime: string;
|
||||
logTopic: string;
|
||||
logMessage: string;
|
||||
logStatus: string;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
<el-button type="primary" @click="addNewBlogClick">新增文章</el-button>
|
||||
</div>
|
||||
|
||||
<el-form :inline="true" :model="blogStore.queryBlog">
|
||||
<el-form-item label="类别" style="margin-bottom: 0;">
|
||||
<el-input v-model="blogStore.queryBlog.category"
|
||||
placeholder="请输入博客类别" clearable
|
||||
@input="changeCategoryClick"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
:data="blogStore.blogList"
|
||||
border stripe
|
||||
@@ -78,6 +86,10 @@ const changePageClick = async (page: number, pageSize: number) => {
|
||||
await blogStore.queryBlogList()
|
||||
}
|
||||
|
||||
const changeCategoryClick = async () => {
|
||||
await blogStore.queryBlogList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击新增博客按钮
|
||||
*/
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div class="admin-module">
|
||||
<div class="button-container">
|
||||
<el-button type="primary" @click="addNewCategoryClick">新增类别</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="blogStore.blogCategoryList"
|
||||
border stripe
|
||||
:header-cell-style="tableHeaderStyle()"
|
||||
:cell-style="tableCellStyle()"
|
||||
:row-style="tableRowStyle()"
|
||||
style="width: 50%"
|
||||
class="card-item">
|
||||
<el-table-column type="index" align="center" width="50" />
|
||||
<el-table-column prop="name" label="名称" align="center" min-width="10%"/>
|
||||
<el-table-column prop="count" label="数量" align="center" min-width="10%" />
|
||||
<el-table-column fixed="right" label="操作" align="center" width="200">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" @click="editCategoryClick(scope.row)">编辑</el-button>
|
||||
<el-button type="danger" @click="deleteCategoryClick(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useBlogStore } from '@/store/blog.ts'
|
||||
import { IBlogCategory } from '@/types/blog.ts'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { tableCellStyle, tableHeaderStyle, tableRowStyle } from 'vue3-common/utils/elUtil'
|
||||
|
||||
const blogStore = useBlogStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await blogStore.queryBlogCategory()
|
||||
})
|
||||
|
||||
/**
|
||||
* 点击新增类别按钮
|
||||
*/
|
||||
const addNewCategoryClick = () => {
|
||||
ElMessageBox.prompt('请输入类别名称', '新增类别', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '请输入类别名称',
|
||||
inputPattern: /^.{0,10}$/,
|
||||
inputErrorMessage: '请输入10字以内的类别名称'
|
||||
})
|
||||
.then(({ value }) => {
|
||||
console.log(value)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击编辑类别按钮
|
||||
* @param category 类别
|
||||
*/
|
||||
const editCategoryClick = async (category: IBlogCategory) => {
|
||||
ElMessageBox.prompt('请输入类别名称', '编辑类别', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
inputValue: category.name,
|
||||
inputPlaceholder: '请输入类别名称',
|
||||
inputPattern: /^.{0,10}$/,
|
||||
inputErrorMessage: '请输入10字以内的类别名称'
|
||||
})
|
||||
.then(({ value }) => {
|
||||
console.log(value)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击删除类别按钮
|
||||
* @param category 类别
|
||||
*/
|
||||
const deleteCategoryClick = (category: IBlogCategory) => {
|
||||
|
||||
}
|
||||
</script>
|
||||
96
src/views/blog/unapproved.vue
Normal file
96
src/views/blog/unapproved.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="admin-module">
|
||||
<div class="button-container">
|
||||
<el-button type="primary" @click="addNewBlogClick">新增文章</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="blogStore.unapprovedBlogList"
|
||||
border stripe
|
||||
:header-cell-style="tableHeaderStyle()"
|
||||
:cell-style="tableCellStyle()"
|
||||
:row-style="tableRowStyle()"
|
||||
class="card-item">
|
||||
<el-table-column type="index" align="center" width="50" />
|
||||
<el-table-column prop="title" label="标题" align="center" min-width="15%"/>
|
||||
<el-table-column prop="category" label="类别" align="center" min-width="10%" />
|
||||
<el-table-column label="精品" align="center" min-width="5%">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.isGreat">是</el-tag>
|
||||
<el-tag type="danger" v-else>否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="置顶" align="center" min-width="5%">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.topValue > 1">是</el-tag>
|
||||
<el-tag type="danger" v-else>否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="wordCount" label="字数" align="center" min-width="10%" />
|
||||
<el-table-column prop="visitCount" label="阅读次数" align="center" min-width="10%" />
|
||||
<el-table-column label="是否发布" align="center" min-width="10%">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.isApproved">已发布</el-tag>
|
||||
<el-tag type="danger" v-else>草稿</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column fixed="right" label="操作" align="center" width="300">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" @click="previewBlogClick(scope.row)">预览</el-button>
|
||||
<el-button type="primary" @click="editBlogClick(scope.row)">编辑</el-button>
|
||||
<el-button type="danger" @click="deleteBlogClick(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useBlogStore } from '@/store/blog.ts'
|
||||
import { IBlog } from '@/types/blog.ts'
|
||||
import { commonElMessageBox, tableCellStyle, tableHeaderStyle, tableRowStyle } from 'vue3-common/utils/elUtil'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const blogStore = useBlogStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await blogStore.queryUnapprovedBlogList()
|
||||
})
|
||||
|
||||
/**
|
||||
* 点击新增博客按钮
|
||||
*/
|
||||
const addNewBlogClick = () => {
|
||||
router.push({ name: 'BlogDetailId', params: { id: 0 } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击预览博客按钮
|
||||
* @param blog 博客
|
||||
*/
|
||||
const previewBlogClick = async (blog: IBlog) => {
|
||||
await router.push({ name: 'BlogPreviewId', params: { id: blog.id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击编辑博客按钮
|
||||
* @param blog 博客
|
||||
*/
|
||||
const editBlogClick = async (blog: IBlog) => {
|
||||
await router.push({ name: 'BlogDetailId', params: { id: blog.id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击删除博客按钮
|
||||
* @param blog 博客
|
||||
*/
|
||||
const deleteBlogClick = (blog: IBlog) => {
|
||||
blogStore.blogApiType = 'DELETE'
|
||||
|
||||
commonElMessageBox('是否确认删除该博客?').then(() => {
|
||||
blogStore.handleBlogApi(blog.id)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,203 @@
|
||||
<template>
|
||||
<div class="admin-module">
|
||||
Dashboard
|
||||
<div class="admin-module blog-dashboard">
|
||||
<div class="blog-overview">
|
||||
<el-statistic class="card-item" title="博客总数" suffix="篇"
|
||||
:value="blogDashboard.overview.blogCount"
|
||||
/>
|
||||
|
||||
<el-statistic class="card-item" title="类别总数" suffix="个"
|
||||
:value="blogDashboard.overview.categoryCount"/>
|
||||
|
||||
<el-statistic class="card-item" title="字数总计" suffix="字"
|
||||
:value="blogDashboard.overview.wordCount" />
|
||||
|
||||
<el-statistic class="card-item" title="访问总计" suffix="人"
|
||||
:value="blogDashboard.overview.visitCount" />
|
||||
</div>
|
||||
|
||||
<div class="blog-chart">
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊每月博客发布统计</span>
|
||||
</div>
|
||||
<div class="chart" ref="approvedMonthlyChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊每月博客访问统计</span>
|
||||
</div>
|
||||
<div class="chart" ref="visitMonthlyChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊博客访问数量排行</span>
|
||||
</div>
|
||||
<div class="chart" ref="visitRankChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊博客类别统计</span>
|
||||
</div>
|
||||
<div class="chart" ref="categoryChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊博客阅读时长排行</span>
|
||||
</div>
|
||||
<div class="chart" ref="readRankChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊博客评论数量排行</span>
|
||||
</div>
|
||||
<div class="chart" ref="commentRankChartRef"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { barChartOptions, lineChartOptions, pieChartOptions } from 'vue3-common/utils/eChartsUtil'
|
||||
import { useAppStore } from '@/store/app.ts'
|
||||
import {
|
||||
queryApprovedMonthlyStatsApi, queryCategoryStatsApi, queryCommentRankStatsApi, queryOverviewStatsApi,
|
||||
queryReadRankStatsApi,
|
||||
queryVisitMonthlyStatsApi,
|
||||
queryVisitRankStatsApi
|
||||
} from '@/apis/stats'
|
||||
import { IBlogOverview } from '@/types/blog'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const approvedMonthlyChartRef = ref<HTMLElement>()
|
||||
const visitMonthlyChartRef = ref<HTMLElement>()
|
||||
const visitRankChartRef = ref<HTMLElement>()
|
||||
const categoryChartRef = ref<HTMLElement>()
|
||||
const readRankChartRef = ref<HTMLElement>()
|
||||
const commentRankChartRef = ref<HTMLElement>()
|
||||
|
||||
let approvedMonthlyChart: echarts.ECharts
|
||||
let visitMonthlyChart: echarts.ECharts
|
||||
let visitRankChart: echarts.ECharts
|
||||
let categoryChart: echarts.ECharts
|
||||
let readRankChart: echarts.ECharts
|
||||
let commentRankChart: echarts.ECharts
|
||||
|
||||
const blogDashboard = reactive({
|
||||
overview: {
|
||||
blogCount: 0,
|
||||
categoryCount: 0,
|
||||
wordCount: 0,
|
||||
greatCount: 0,
|
||||
visitCount: 0
|
||||
} as IBlogOverview
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
approvedMonthlyChart = echarts.init(approvedMonthlyChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
visitMonthlyChart = echarts.init(visitMonthlyChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
visitRankChart = echarts.init(visitRankChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
categoryChart = echarts.init(categoryChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
readRankChart = echarts.init(readRankChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
commentRankChart = echarts.init(commentRankChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
|
||||
updateChart()
|
||||
})
|
||||
|
||||
const updateChart = async () => {
|
||||
blogDashboard.overview = await queryOverviewStatsApi()
|
||||
|
||||
const approvedMonthlyData = await queryApprovedMonthlyStatsApi(2025)
|
||||
const approvedXAxis = approvedMonthlyData.map((item) => item.name)
|
||||
const approvedYAxis = approvedMonthlyData.map((item) => item.value)
|
||||
approvedMonthlyChart.setOption(lineChartOptions('日期', approvedXAxis, '数量(篇)', approvedYAxis))
|
||||
|
||||
const visitMonthlyData = await queryVisitMonthlyStatsApi(2025)
|
||||
const visitMonthlyXAxis = visitMonthlyData.map((item) => item.name)
|
||||
const visitMonthlyYAxis = visitMonthlyData.map((item) => item.value)
|
||||
visitMonthlyChart.setOption(lineChartOptions('日期', visitMonthlyXAxis, '访问量(人)', visitMonthlyYAxis))
|
||||
|
||||
const visitRankData = await queryVisitRankStatsApi()
|
||||
const visitRankXAxis = visitRankData.map((item) => item.name)
|
||||
const visitRankYAxis = visitRankData.map((item) => item.value)
|
||||
visitRankChart.setOption(barChartOptions('名称', visitRankXAxis, '访问量(人)', visitRankYAxis))
|
||||
|
||||
const categoryData = await queryCategoryStatsApi()
|
||||
categoryChart.setOption(pieChartOptions('类别', '篇', categoryData))
|
||||
|
||||
const readRankData = await queryReadRankStatsApi()
|
||||
const readRankXAxis = readRankData.map((item) => item.name)
|
||||
const readRankYAxis = readRankData.map((item) => item.value)
|
||||
readRankChart.setOption(barChartOptions('名称', readRankXAxis, '时长(分钟)', readRankYAxis))
|
||||
|
||||
const commentRankData = await queryCommentRankStatsApi()
|
||||
const commentRankXAxis = commentRankData.map((item) => item.name)
|
||||
const commentRankYAxis = commentRankData.map((item) => item.value)
|
||||
commentRankChart.setOption(barChartOptions('名称', commentRankXAxis, '评论量(个)', commentRankYAxis))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style lang="scss">
|
||||
.blog-dashboard {
|
||||
overflow: auto;
|
||||
|
||||
.blog-overview {
|
||||
width: 100%;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
|
||||
.el-statistic {
|
||||
padding: 10px;
|
||||
|
||||
.el-statistic__head {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.el-statistic__number {
|
||||
font-size: xx-large;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blog-chart {
|
||||
flex-grow: 1;
|
||||
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
|
||||
.chart-container {
|
||||
display: grid;
|
||||
grid-template-rows: 40px 1fr;
|
||||
|
||||
.content {
|
||||
padding: 0 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #009FA8;
|
||||
|
||||
.title {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,9 +21,9 @@ export const metaList: IRouteMetaConfig = {
|
||||
title: '文章管理',
|
||||
icon: 'admin-blogArticle'
|
||||
},
|
||||
'/blog/category': {
|
||||
title: '类别管理',
|
||||
icon: 'admin-blogCategory'
|
||||
'/blog/unapproved': {
|
||||
title: '草稿箱',
|
||||
icon: 'admin-blogUnapproved'
|
||||
},
|
||||
'/blog/detail/:id': {
|
||||
title: '',
|
||||
@@ -37,12 +37,16 @@ export const metaList: IRouteMetaConfig = {
|
||||
},
|
||||
'/monitor': {
|
||||
title: '监控管理',
|
||||
icon: 'admin-blogVisit',
|
||||
icon: 'admin-blogMonitor',
|
||||
order: 3,
|
||||
redirect: '/monitor/visit'
|
||||
},
|
||||
'/monitor/visit': {
|
||||
title: '监控管理',
|
||||
icon: ''
|
||||
title: '访问记录',
|
||||
icon: 'admin-blogVisit'
|
||||
},
|
||||
'/monitor/alter': {
|
||||
title: '告警记录',
|
||||
icon: 'admin-blogLog'
|
||||
}
|
||||
}
|
||||
|
||||
101
src/views/monitor/alter.vue
Normal file
101
src/views/monitor/alter.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="admin-module">
|
||||
<el-table
|
||||
:data="blogStore.alterLogList"
|
||||
border stripe
|
||||
:span-method="handleSpanMethod"
|
||||
:header-cell-style="tableHeaderStyle()"
|
||||
:cell-style="tableCellStyle()"
|
||||
:row-style="tableRowStyle()"
|
||||
style="width: 100%"
|
||||
class="card-item">
|
||||
<el-table-column type="index" align="center" width="50" />
|
||||
<el-table-column prop="alterTime" label="告警时间" align="center" width="150px" />
|
||||
<el-table-column prop="logTime" label="日志时间" align="center" width="150px" />
|
||||
<el-table-column prop="logTopic" label="日志话题" align="center" width="150px" />
|
||||
<el-table-column prop="logMessage" label="日志消息" align="center"/>
|
||||
<el-table-column prop="logStatus" label="告警状态" align="center" width="100px">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.logStatus === 'pending'" type="danger">待处理</el-tag>
|
||||
<el-tag v-if="scope.row.logStatus === 'complete'" type="success">已完成</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200px" align="center">
|
||||
<template #default="scope">
|
||||
<el-button type="primary">下载</el-button>
|
||||
<el-button type="primary" :disabled="scope.row.logStatus === 'pending'">
|
||||
已处理
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { tableCellStyle, tableHeaderStyle, tableRowStyle } from 'vue3-common/utils/elUtil'
|
||||
import { useBlogStore } from '@/store/blog'
|
||||
|
||||
const blogStore = useBlogStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await blogStore.queryBlogAlterLogList()
|
||||
})
|
||||
|
||||
const handleSpanMethod = ({ row, column, rowIndex, columnIndex }) => {
|
||||
const data = blogStore.alterLogList
|
||||
|
||||
// 合并规则:相同 alterTime + logStatus 的行合并
|
||||
if (column.property === 'alterTime' || column.property === 'logStatus') {
|
||||
const currentAlterTime = row.alterTime
|
||||
const currentLogStatus = row.logStatus
|
||||
|
||||
// 检查是否和上一行的告警时间和状态相同
|
||||
if (rowIndex > 0) {
|
||||
const prevRow = data[rowIndex - 1]
|
||||
if (prevRow.alterTime === currentAlterTime && prevRow.logStatus === currentLogStatus) {
|
||||
return { rowspan: 0, colspan: 0 } // 隐藏当前单元格
|
||||
}
|
||||
}
|
||||
|
||||
// 计算连续相同 alterTime + logStatus 的行数
|
||||
let rowspan = 1
|
||||
for (let i = rowIndex + 1; i < data.length; i++) {
|
||||
const nextRow = data[i]
|
||||
if (nextRow.alterTime === currentAlterTime && nextRow.logStatus === currentLogStatus) {
|
||||
rowspan++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return { rowspan, colspan: 1 }
|
||||
}
|
||||
|
||||
// 合并操作列(最后一列)
|
||||
if (columnIndex === 6) { // 操作列是第7列(索引从0开始)
|
||||
const currentAlterTime = row.alterTime
|
||||
const currentLogStatus = row.logStatus
|
||||
|
||||
// 检查是否和上一行的告警时间和状态相同
|
||||
if (rowIndex > 0) {
|
||||
const prevRow = data[rowIndex - 1]
|
||||
if (prevRow.alterTime === currentAlterTime && prevRow.logStatus === currentLogStatus) {
|
||||
return { rowspan: 0, colspan: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 计算连续相同 alterTime + logStatus 的行数
|
||||
let rowspan = 1
|
||||
for (let i = rowIndex + 1; i < data.length; i++) {
|
||||
const nextRow = data[i]
|
||||
if (nextRow.alterTime === currentAlterTime && nextRow.logStatus === currentLogStatus) {
|
||||
rowspan++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return { rowspan, colspan: 1 }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -40,6 +40,11 @@ export default defineConfig({
|
||||
target: `http://${API_HOST}:8082`,
|
||||
changeOrigin: true,
|
||||
rewrite: (item) => item.replace(/^\/blog-api/, '')
|
||||
},
|
||||
'/alter-api': {
|
||||
target: `http://${API_HOST}:8000`,
|
||||
changeOrigin: true,
|
||||
rewrite: (item) => item.replace(/^\/alter-api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user