refactor: widgets of dashboard page (#736)

#### What type of PR is this?

/kind improvement
/milestone 2.0

#### What this PR does / why we need it:

优化仪表盘小部件的样式和数据获取方式。

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/2649

#### Screenshots:

<img width="1664" alt="image" src="https://user-images.githubusercontent.com/21301288/204808668-29d65e42-eabc-4598-9a9e-03597d6a0344.png">

#### Special notes for your reviewer:

None

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/734/head^2
Ryan Wang 2022-11-30 22:51:49 +08:00 committed by GitHub
parent c20767a30a
commit c26e438420
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 330 additions and 208 deletions

View File

@ -77,6 +77,7 @@
"@iconify-json/vscode-icons": "^1.1.16", "@iconify-json/vscode-icons": "^1.1.16",
"@rushstack/eslint-patch": "^1.2.0", "@rushstack/eslint-patch": "^1.2.0",
"@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/container-queries": "^0.1.0",
"@types/jsdom": "^20.0.1", "@types/jsdom": "^20.0.1",
"@types/lodash.clonedeep": "4.5.7", "@types/lodash.clonedeep": "4.5.7",
"@types/lodash.debounce": "^4.0.7", "@types/lodash.debounce": "^4.0.7",
@ -118,7 +119,6 @@
"vite-plugin-html": "^3.2.0", "vite-plugin-html": "^3.2.0",
"vite-plugin-pwa": "^0.13.3", "vite-plugin-pwa": "^0.13.3",
"vite-plugin-static-copy": "^0.11.1", "vite-plugin-static-copy": "^0.11.1",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vitest": "^0.25.3", "vitest": "^0.25.3",
"vue-tsc": "^1.0.9" "vue-tsc": "^1.0.9"
} }

View File

@ -20,7 +20,7 @@ export interface Plugin {
/** /**
* These components will be registered when plugin is activated. * These components will be registered when plugin is activated.
*/ */
components?: Component[]; components?: Record<string, Component>;
/** /**
* Activate hook will be called when plugin is activated. * Activate hook will be called when plugin is activated.

View File

@ -20,6 +20,7 @@ importers:
'@iconify-json/vscode-icons': ^1.1.16 '@iconify-json/vscode-icons': ^1.1.16
'@rushstack/eslint-patch': ^1.2.0 '@rushstack/eslint-patch': ^1.2.0
'@tailwindcss/aspect-ratio': ^0.4.2 '@tailwindcss/aspect-ratio': ^0.4.2
'@tailwindcss/container-queries': ^0.1.0
'@tiptap/extension-character-count': ^2.0.0-beta.202 '@tiptap/extension-character-count': ^2.0.0-beta.202
'@types/jsdom': ^20.0.1 '@types/jsdom': ^20.0.1
'@types/lodash.clonedeep': 4.5.7 '@types/lodash.clonedeep': 4.5.7
@ -90,7 +91,6 @@ importers:
vite-plugin-html: ^3.2.0 vite-plugin-html: ^3.2.0
vite-plugin-pwa: ^0.13.3 vite-plugin-pwa: ^0.13.3
vite-plugin-static-copy: ^0.11.1 vite-plugin-static-copy: ^0.11.1
vite-plugin-vue-setup-extend: ^0.4.0
vitest: ^0.25.3 vitest: ^0.25.3
vue: ^3.2.45 vue: ^3.2.45
vue-grid-layout: 3.0.0-beta1 vue-grid-layout: 3.0.0-beta1
@ -150,6 +150,7 @@ importers:
'@iconify-json/vscode-icons': 1.1.16 '@iconify-json/vscode-icons': 1.1.16
'@rushstack/eslint-patch': 1.2.0 '@rushstack/eslint-patch': 1.2.0
'@tailwindcss/aspect-ratio': 0.4.2_tailwindcss@3.2.4 '@tailwindcss/aspect-ratio': 0.4.2_tailwindcss@3.2.4
'@tailwindcss/container-queries': 0.1.0_tailwindcss@3.2.4
'@types/jsdom': 20.0.1 '@types/jsdom': 20.0.1
'@types/lodash.clonedeep': 4.5.7 '@types/lodash.clonedeep': 4.5.7
'@types/lodash.debounce': 4.0.7 '@types/lodash.debounce': 4.0.7
@ -180,7 +181,7 @@ importers:
randomstring: 1.2.3 randomstring: 1.2.3
sass: 1.56.1 sass: 1.56.1
start-server-and-test: 1.14.0 start-server-and-test: 1.14.0
tailwindcss: 3.2.4 tailwindcss: 3.2.4_postcss@8.4.19
tailwindcss-safe-area: 0.2.2 tailwindcss-safe-area: 0.2.2
tailwindcss-themer: 2.0.2_tailwindcss@3.2.4 tailwindcss-themer: 2.0.2_tailwindcss@3.2.4
typescript: 4.7.4 typescript: 4.7.4
@ -191,7 +192,6 @@ importers:
vite-plugin-html: 3.2.0_vite@3.2.4 vite-plugin-html: 3.2.0_vite@3.2.4
vite-plugin-pwa: 0.13.3_vite@3.2.4 vite-plugin-pwa: 0.13.3_vite@3.2.4
vite-plugin-static-copy: 0.11.1_vite@3.2.4 vite-plugin-static-copy: 0.11.1_vite@3.2.4
vite-plugin-vue-setup-extend: 0.4.0_vite@3.2.4
vitest: 0.25.3_wqgykbafw5ps5nhnqfbxcvabhu vitest: 0.25.3_wqgykbafw5ps5nhnqfbxcvabhu
vue-tsc: 1.0.9_typescript@4.7.4 vue-tsc: 1.0.9_typescript@4.7.4
@ -1931,7 +1931,7 @@ packages:
optional: true optional: true
dependencies: dependencies:
'@formkit/core': 1.0.0-beta.12-e579559 '@formkit/core': 1.0.0-beta.12-e579559
tailwindcss: 3.2.4 tailwindcss: 3.2.4_postcss@8.4.19
dev: false dev: false
/@formkit/utils/1.0.0-beta.12-e579559: /@formkit/utils/1.0.0-beta.12-e579559:
@ -2675,7 +2675,15 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1' tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
dependencies: dependencies:
tailwindcss: 3.2.4 tailwindcss: 3.2.4_postcss@8.4.19
dev: true
/@tailwindcss/container-queries/0.1.0_tailwindcss@3.2.4:
resolution: {integrity: sha512-t1GeJ9P8ual160BvKy6Y1sG7bjChArMaK6iRXm3ZYjZGN2FTzmqb5ztsTDb9AsTSJD4NMHtsnaI2ielrXEk+hw==}
peerDependencies:
tailwindcss: '>=3.2.0'
dependencies:
tailwindcss: 3.2.4_postcss@8.4.19
dev: true dev: true
/@tiptap/core/2.0.0-beta.202: /@tiptap/core/2.0.0-beta.202:
@ -8532,13 +8540,15 @@ packages:
just-unique: 4.1.1 just-unique: 4.1.1
lodash.merge: 4.6.2 lodash.merge: 4.6.2
lodash.mergewith: 4.6.2 lodash.mergewith: 4.6.2
tailwindcss: 3.2.4 tailwindcss: 3.2.4_postcss@8.4.19
dev: true dev: true
/tailwindcss/3.2.4: /tailwindcss/3.2.4_postcss@8.4.19:
resolution: {integrity: sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==} resolution: {integrity: sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==}
engines: {node: '>=12.13.0'} engines: {node: '>=12.13.0'}
hasBin: true hasBin: true
peerDependencies:
postcss: ^8.0.9
dependencies: dependencies:
arg: 5.0.2 arg: 5.0.2
chokidar: 3.5.3 chokidar: 3.5.3
@ -9173,16 +9183,6 @@ packages:
vite: 3.2.4_ajklay5k626t46b6fyghkbup3i vite: 3.2.4_ajklay5k626t46b6fyghkbup3i
dev: true dev: true
/vite-plugin-vue-setup-extend/0.4.0_vite@3.2.4:
resolution: {integrity: sha512-WMbjPCui75fboFoUTHhdbXzu4Y/bJMv5N9QT9a7do3wNMNHHqrk+Tn2jrSJU0LS5fGl/EG+FEDBYVUeWIkDqXQ==}
peerDependencies:
vite: '>=2.0.0'
dependencies:
'@vue/compiler-sfc': 3.2.45
magic-string: 0.25.9
vite: 3.2.4_ajklay5k626t46b6fyghkbup3i
dev: true
/vite/3.2.4: /vite/3.2.4:
resolution: {integrity: sha512-Z2X6SRAffOUYTa+sLy3NQ7nlHFU100xwanq1WDwqaiFiCe+25zdxP1TfCS5ojPV2oDDcXudHIoPnI1Z/66B7Yw==} resolution: {integrity: sha512-Z2X6SRAffOUYTa+sLy3NQ7nlHFU100xwanq1WDwqaiFiCe+25zdxP1TfCS5ojPV2oDDcXudHIoPnI1Z/66B7Yw==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}

View File

@ -28,14 +28,12 @@ app.use(createPinia());
function registerModule(pluginModule: Plugin, core: boolean) { function registerModule(pluginModule: Plugin, core: boolean) {
if (pluginModule.components) { if (pluginModule.components) {
if (!Array.isArray(pluginModule.components)) { Object.keys(pluginModule.components).forEach((key) => {
console.error(`${pluginModule.name}: Plugin components must be an array`); const component = pluginModule.components?.[key];
return; if (component) {
} app.component(key, component);
}
for (const component of pluginModule.components) { });
component.name && app.component(component.name, component);
}
} }
if (pluginModule.routes) { if (pluginModule.routes) {

View File

@ -7,7 +7,9 @@ import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "attachmentModule", name: "attachmentModule",
components: [AttachmentSelectorModal], components: {
AttachmentSelectorModal,
},
routes: [ routes: [
{ {
path: "/attachments", path: "/attachments",

View File

@ -2,11 +2,14 @@ import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue"; import BasicLayout from "@/layouts/BasicLayout.vue";
import { IconMessage } from "@halo-dev/components"; import { IconMessage } from "@halo-dev/components";
import CommentList from "./CommentList.vue"; import CommentList from "./CommentList.vue";
import CommentStatsWidget from "./widgets/CommentStatsWidget.vue";
import { markRaw } from "vue"; import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "commentModule", name: "commentModule",
components: [], components: {
CommentStatsWidget,
},
routes: [ routes: [
{ {
path: "/comments", path: "/comments",

View File

@ -0,0 +1,27 @@
<script lang="ts" setup>
import type { DashboardStats } from "@halo-dev/api-client";
import { VCard, IconMessage } from "@halo-dev/components";
import { inject, type Ref } from "vue";
const dashboardStats = inject<Ref<DashboardStats>>("dashboardStats");
</script>
<template>
<VCard class="h-full" :body-class="['h-full']">
<div class="flex h-full">
<div class="flex items-center gap-4">
<span
class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconMessage class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">评论</span>
<p class="text-2xl font-medium text-gray-900">
{{ dashboardStats?.approvedComments }}
</p>
</div>
</div>
</div>
</VCard>
</template>

View File

@ -6,12 +6,15 @@ import FunctionalPageList from "./FunctionalPageList.vue";
import SinglePageList from "./SinglePageList.vue"; import SinglePageList from "./SinglePageList.vue";
import DeletedSinglePageList from "./DeletedSinglePageList.vue"; import DeletedSinglePageList from "./DeletedSinglePageList.vue";
import SinglePageEditor from "./SinglePageEditor.vue"; import SinglePageEditor from "./SinglePageEditor.vue";
import SinglePageStatsWidget from "./widgets/SinglePageStatsWidget.vue";
import { IconPages } from "@halo-dev/components"; import { IconPages } from "@halo-dev/components";
import { markRaw } from "vue"; import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "pageModule", name: "pageModule",
components: [], components: {
SinglePageStatsWidget,
},
routes: [ routes: [
{ {
path: "/pages", path: "/pages",

View File

@ -0,0 +1,42 @@
<script lang="ts" setup>
import { VCard, IconPages } from "@halo-dev/components";
import { onMounted, ref } from "vue";
import { apiClient } from "@/utils/api-client";
import { singlePageLabels } from "@/constants/labels";
const singlePageTotal = ref<number>(0);
const handleFetchSinglePages = async () => {
const { data } = await apiClient.singlePage.listSinglePages({
labelSelector: [
`${singlePageLabels.DELETED}=false`,
`${singlePageLabels.PUBLISHED}=true`,
],
page: 0,
size: 0,
});
singlePageTotal.value = data.total;
};
onMounted(handleFetchSinglePages);
</script>
<template>
<VCard class="h-full" :body-class="['h-full']">
<div class="flex h-full">
<div class="flex items-center gap-4">
<span
class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconPages class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">页面</span>
<p class="text-2xl font-medium text-gray-900">
{{ singlePageTotal || 0 }}
</p>
</div>
</div>
</div>
</VCard>
</template>

View File

@ -7,11 +7,16 @@ import DeletedPostList from "./DeletedPostList.vue";
import PostEditor from "./PostEditor.vue"; import PostEditor from "./PostEditor.vue";
import CategoryList from "./categories/CategoryList.vue"; import CategoryList from "./categories/CategoryList.vue";
import TagList from "./tags/TagList.vue"; import TagList from "./tags/TagList.vue";
import PostStatsWidget from "./widgets/PostStatsWidget.vue";
import RecentPublishedWidget from "./widgets/RecentPublishedWidget.vue";
import { markRaw } from "vue"; import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "postModule", name: "postModule",
components: [], components: {
PostStatsWidget,
RecentPublishedWidget,
},
routes: [ routes: [
{ {
path: "/posts", path: "/posts",

View File

@ -0,0 +1,27 @@
<script lang="ts" setup>
import { VCard, IconBookRead } from "@halo-dev/components";
import { inject, type Ref } from "vue";
import type { DashboardStats } from "@halo-dev/api-client/index";
const dashboardStats = inject<Ref<DashboardStats>>("dashboardStats");
</script>
<template>
<VCard class="h-full" :body-class="['h-full']">
<div class="flex h-full">
<div class="flex items-center gap-4">
<span
class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconBookRead class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">文章</span>
<p class="text-2xl font-medium text-gray-900">
{{ dashboardStats?.posts || 0 }}
</p>
</div>
</div>
</div>
</VCard>
</template>

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import {
VCard,
VSpace,
VEntity,
VEntityField,
IconExternalLinkLine,
} from "@halo-dev/components";
import { onMounted, ref } from "vue";
import type { ListedPost } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date";
import { postLabels } from "@/constants/labels";
const posts = ref<ListedPost[]>([] as ListedPost[]);
const handleFetchPosts = async () => {
try {
const { data } = await apiClient.post.listPosts({
labelSelector: [
`${postLabels.DELETED}=false`,
`${postLabels.PUBLISHED}=true`,
],
sort: "PUBLISH_TIME",
sortOrder: false,
page: 1,
size: 10,
});
posts.value = data.items;
} catch (e) {
console.error("Failed to fetch posts", e);
}
};
onMounted(handleFetchPosts);
</script>
<template>
<VCard
:body-class="['h-full', '!p-0', 'overflow-y-auto']"
class="h-full"
title="最近文章"
>
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="(post, index) in posts" :key="index">
<VEntity>
<template #start>
<VEntityField
:title="post.post.spec.title"
:route="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
>
<template #description>
<VSpace>
<span class="text-xs text-gray-500">
访问量 {{ post.stats.visit || 0 }}
</span>
<span class="text-xs text-gray-500">
评论 {{ post.stats.totalComment || 0 }}
</span>
</VSpace>
</template>
<template #extra>
<a
v-if="post.post.status?.permalink"
target="_blank"
:href="post.post.status?.permalink"
:title="post.post.status?.permalink"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(post.post.spec.publishTime) }}
</span>
</template>
</VEntityField>
</template>
</VEntity>
</li>
</ul>
</VCard>
</template>

View File

@ -114,9 +114,11 @@ import {
VSpace, VSpace,
VTabbar, VTabbar,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { ref } from "vue"; import { onMounted, provide, ref, type Ref } from "vue";
import { useStorage } from "@vueuse/core"; import { useStorage } from "@vueuse/core";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client";
import type { DashboardStats } from "@halo-dev/api-client/index";
const widgetsGroup = [ const widgetsGroup = [
{ {
@ -127,6 +129,13 @@ const widgetsGroup = [
{ x: 0, y: 0, w: 6, h: 10, i: 1, widget: "RecentPublishedWidget" }, { x: 0, y: 0, w: 6, h: 10, i: 1, widget: "RecentPublishedWidget" },
], ],
}, },
{
id: "page",
label: "页面",
widgets: [
{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "SinglePageStatsWidget" },
],
},
{ {
id: "comment", id: "comment",
label: "评论", label: "评论",
@ -137,7 +146,7 @@ const widgetsGroup = [
label: "用户", label: "用户",
widgets: [ widgets: [
{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "UserStatsWidget" }, { x: 0, y: 0, w: 3, h: 3, i: 0, widget: "UserStatsWidget" },
{ x: 0, y: 0, w: 6, h: 10, i: 1, widget: "RecentLoginWidget" }, { x: 0, y: 0, w: 3, h: 3, i: 1, widget: "UserProfileWidget" },
], ],
}, },
{ {
@ -159,14 +168,13 @@ const layout = useStorage("widgets", [
{ x: 3, y: 0, w: 3, h: 3, i: 1, widget: "UserStatsWidget" }, { x: 3, y: 0, w: 3, h: 3, i: 1, widget: "UserStatsWidget" },
{ x: 6, y: 0, w: 3, h: 3, i: 2, widget: "CommentStatsWidget" }, { x: 6, y: 0, w: 3, h: 3, i: 2, widget: "CommentStatsWidget" },
{ x: 9, y: 0, w: 3, h: 3, i: 3, widget: "ViewsStatsWidget" }, { x: 9, y: 0, w: 3, h: 3, i: 3, widget: "ViewsStatsWidget" },
{ x: 8, y: 3, w: 4, h: 10, i: 4, widget: "RecentLoginWidget" }, { x: 0, y: 3, w: 4, h: 10, i: 4, widget: "QuickLinkWidget" },
{ x: 0, y: 3, w: 4, h: 10, i: 5, widget: "QuickLinkWidget" },
{ {
x: 4, x: 4,
y: 3, y: 3,
w: 4, w: 4,
h: 10, h: 10,
i: 6, i: 5,
widget: "RecentPublishedWidget", widget: "RecentPublishedWidget",
}, },
]); ]);
@ -194,12 +202,32 @@ function handleRemove(item: any) {
}; };
}); });
} }
// Dashboard basic stats
const dashboardStats = ref<DashboardStats>({
posts: 0,
comments: 0,
approvedComments: 0,
users: 0,
visits: 0,
});
provide<Ref<DashboardStats>>("dashboardStats", dashboardStats);
const handleFetchStats = async () => {
const { data } = await apiClient.stats.getStats();
dashboardStats.value = data;
};
onMounted(handleFetchStats);
</script> </script>
<style> <style>
.vue-grid-layout { .vue-grid-layout {
@apply -m-[10px]; @apply -m-[10px];
} }
.vue-grid-item { .vue-grid-item {
transition: none !important; transition: none !important;
} }

View File

@ -3,26 +3,16 @@ import BasicLayout from "@/layouts/BasicLayout.vue";
import Dashboard from "./Dashboard.vue"; import Dashboard from "./Dashboard.vue";
import { IconDashboard } from "@halo-dev/components"; import { IconDashboard } from "@halo-dev/components";
import CommentStatsWidget from "./widgets/CommentStatsWidget.vue";
import PostStatsWidget from "./widgets/PostStatsWidget.vue";
import QuickLinkWidget from "./widgets/QuickLinkWidget.vue"; import QuickLinkWidget from "./widgets/QuickLinkWidget.vue";
import RecentLoginWidget from "./widgets/RecentLoginWidget.vue";
import RecentPublishedWidget from "./widgets/RecentPublishedWidget.vue";
import UserStatsWidget from "./widgets/UserStatsWidget.vue";
import ViewsStatsWidget from "./widgets/ViewsStatsWidget.vue"; import ViewsStatsWidget from "./widgets/ViewsStatsWidget.vue";
import { markRaw } from "vue"; import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "dashboardModule", name: "dashboardModule",
components: [ components: {
CommentStatsWidget,
PostStatsWidget,
QuickLinkWidget, QuickLinkWidget,
RecentLoginWidget,
RecentPublishedWidget,
UserStatsWidget,
ViewsStatsWidget, ViewsStatsWidget,
], },
routes: [ routes: [
{ {
path: "/", path: "/",

View File

@ -1,23 +0,0 @@
<script lang="ts" name="CommentStatsWidget" setup>
import { apiClient } from "@/utils/api-client";
import { VCard } from "@halo-dev/components";
import { onMounted, ref } from "vue";
const commentTotal = ref<number>(0);
const handleFetchComments = async () => {
const { data } =
await apiClient.extension.comment.listcontentHaloRunV1alpha1Comment();
commentTotal.value = data.total;
};
onMounted(handleFetchComments);
</script>
<template>
<VCard class="h-full">
<dt class="truncate text-sm font-medium text-gray-500">评论</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ commentTotal }}
</dd>
</VCard>
</template>

View File

@ -1,21 +0,0 @@
<script lang="ts" name="PostStatsWidget" setup>
import { VCard } from "@halo-dev/components";
import { onMounted, ref } from "vue";
import { apiClient } from "@/utils/api-client";
const postTotal = ref<number>(0);
const handleFetchPosts = async () => {
const { data } =
await apiClient.extension.post.listcontentHaloRunV1alpha1Post();
postTotal.value = data.total;
};
onMounted(handleFetchPosts);
</script>
<template>
<VCard class="h-full">
<dt class="truncate text-sm font-medium text-gray-500">文章</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">{{ postTotal }}</dd>
</VCard>
</template>

View File

@ -1,4 +1,4 @@
<script lang="ts" name="QuickLinkWidget" setup> <script lang="ts" setup>
import { import {
IconArrowRight, IconArrowRight,
IconBookRead, IconBookRead,
@ -117,16 +117,18 @@ const actions: Action[] = [
</script> </script>
<template> <template>
<VCard <VCard
:body-class="['h-full', 'overflow-y-auto', '!p-0']" :body-class="['h-full', 'overflow-y-auto', '@container']"
class="h-full" class="h-full"
title="快捷访问" title="快捷访问"
> >
<div class="overflow-hidden sm:grid sm:grid-cols-3 sm:gap-px"> <div
class="grid grid-cols-1 gap-2 overflow-hidden @sm:grid-cols-2 @md:grid-cols-3"
>
<div <div
v-for="(action, index) in actions" v-for="(action, index) in actions"
:key="index" :key="index"
v-permission="action.permissions" v-permission="action.permissions"
class="group relative cursor-pointer bg-white p-6 transition-all hover:bg-gray-50" class="group relative cursor-pointer rounded-lg bg-gray-50 p-4 transition-all hover:bg-gray-100"
@click="action.action" @click="action.action"
> >
<div> <div>
@ -137,14 +139,13 @@ const actions: Action[] = [
</span> </span>
</div> </div>
<div class="mt-8"> <div class="mt-8">
<h3 class="text-base font-medium"> <h3 class="text-sm font-semibold">
<span aria-hidden="true" class="absolute inset-0"></span>
{{ action.title }} {{ action.title }}
</h3> </h3>
</div> </div>
<span <span
aria-hidden="true" aria-hidden="true"
class="pointer-events-none absolute top-6 right-6 text-gray-300 group-hover:text-gray-400" class="pointer-events-none absolute top-6 right-6 text-gray-300 transition-all group-hover:translate-x-1 group-hover:text-gray-400"
> >
<IconArrowRight /> <IconArrowRight />
</span> </span>

View File

@ -1,68 +0,0 @@
<script lang="ts" name="RecentPublishedWidget" setup>
import { VCard, VSpace } from "@halo-dev/components";
import { onMounted, ref } from "vue";
import type { ListedPost } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date";
import { postLabels } from "@/constants/labels";
const posts = ref<ListedPost[]>([] as ListedPost[]);
const handleFetchPosts = async () => {
try {
const { data } = await apiClient.post.listPosts({
labelSelector: [`${postLabels.PUBLISHED}=true`],
sort: "PUBLISH_TIME",
sortOrder: false,
page: 1,
size: 10,
});
posts.value = data.items;
} catch (e) {
console.error("Failed to fetch posts", e);
}
};
onMounted(handleFetchPosts);
</script>
<template>
<VCard
:body-class="['h-full', '!p-0', 'overflow-y-auto']"
class="h-full"
title="最近文章"
>
<div class="h-full">
<ul class="divide-y divide-gray-200" role="list">
<li
v-for="(post, index) in posts"
:key="index"
class="cursor-pointer p-4 hover:bg-gray-50"
>
<div class="flex items-center space-x-4">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-gray-900">
{{ post.post.spec.title }}
</p>
<div class="mt-1 flex">
<VSpace>
<span class="text-xs text-gray-500">
阅读 {{ post.stats.visit }}
</span>
<span class="text-xs text-gray-500">
评论 {{ post.stats.totalComment }}
</span>
</VSpace>
</div>
</div>
<div>
<time class="text-sm tabular-nums text-gray-500">
{{ formatDatetime(post.post.spec.publishTime) }}
</time>
</div>
</div>
</li>
</ul>
</div>
</VCard>
</template>

View File

@ -1,13 +0,0 @@
<script lang="ts" name="UserStatsWidget" setup>
import { VCard } from "@halo-dev/components";
import { useUserFetch } from "@/modules/system/users/composables/use-user";
const { users } = useUserFetch({ fetchOnMounted: true });
</script>
<template>
<VCard class="h-full">
<dt class="truncate text-sm font-medium text-gray-500">用户</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ users.length }}
</dd>
</VCard>
</template>

View File

@ -1,25 +1,27 @@
<script lang="ts" name="ViewsStatsWidget" setup> <script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import type { DashboardStats } from "@halo-dev/api-client"; import type { DashboardStats } from "@halo-dev/api-client";
import { VCard } from "@halo-dev/components"; import { VCard, IconEye } from "@halo-dev/components";
import { onMounted, ref } from "vue"; import { inject, type Ref } from "vue";
const stats = ref<DashboardStats>({ const dashboardStats = inject<Ref<DashboardStats>>("dashboardStats");
visits: 0,
});
const handleFetchStats = async () => {
const { data } = await apiClient.stats.getStats();
stats.value = data;
};
onMounted(handleFetchStats);
</script> </script>
<template> <template>
<VCard class="h-full"> <VCard class="h-full" :body-class="['h-full']">
<dt class="truncate text-sm font-medium text-gray-500">浏览量</dt> <div class="flex h-full">
<dd class="mt-1 text-3xl font-semibold text-gray-900"> <div class="flex items-center gap-4">
{{ stats.visits || 0 }} <span
</dd> class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconEye class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">浏览量</span>
<p class="text-2xl font-medium text-gray-900">
{{ dashboardStats?.visits || 0 }}
</p>
</div>
</div>
</div>
</VCard> </VCard>
</template> </template>

View File

@ -6,7 +6,7 @@ import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "menuModule", name: "menuModule",
components: [], components: {},
routes: [ routes: [
{ {
path: "/menus", path: "/menus",

View File

@ -7,7 +7,7 @@ import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "themeModule", name: "themeModule",
components: [], components: {},
routes: [ routes: [
{ {
path: "/theme", path: "/theme",

View File

@ -10,7 +10,7 @@ import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "pluginModule", name: "pluginModule",
components: [], components: {},
routes: [ routes: [
{ {
path: "/plugins", path: "/plugins",

View File

@ -5,7 +5,7 @@ import RoleDetail from "./RoleDetail.vue";
export default definePlugin({ export default definePlugin({
name: "roleModule", name: "roleModule",
components: [], components: {},
routes: [ routes: [
{ {
path: "/users", path: "/users",

View File

@ -6,7 +6,7 @@ import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "settingModule", name: "settingModule",
components: [], components: {},
routes: [ routes: [
{ {
path: "/settings", path: "/settings",

View File

@ -2,6 +2,7 @@ import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue"; import BasicLayout from "@/layouts/BasicLayout.vue";
import BlankLayout from "@/layouts/BlankLayout.vue"; import BlankLayout from "@/layouts/BlankLayout.vue";
import UserProfileLayout from "./layouts/UserProfileLayout.vue"; import UserProfileLayout from "./layouts/UserProfileLayout.vue";
import UserStatsWidget from "./widgets/UserStatsWidget.vue";
import UserList from "./UserList.vue"; import UserList from "./UserList.vue";
import UserDetail from "./UserDetail.vue"; import UserDetail from "./UserDetail.vue";
import PersonalAccessTokens from "./PersonalAccessTokens.vue"; import PersonalAccessTokens from "./PersonalAccessTokens.vue";
@ -11,7 +12,9 @@ import { markRaw } from "vue";
export default definePlugin({ export default definePlugin({
name: "userModule", name: "userModule",
components: [], components: {
UserStatsWidget,
},
routes: [ routes: [
{ {
path: "/login", path: "/login",

View File

@ -1,4 +1,4 @@
<script lang="ts" name="RecentLoginWidget" setup> <script lang="ts" setup>
import { VCard, VAvatar } from "@halo-dev/components"; import { VCard, VAvatar } from "@halo-dev/components";
import { useUserFetch } from "@/modules/system/users/composables/use-user"; import { useUserFetch } from "@/modules/system/users/composables/use-user";

View File

@ -0,0 +1,27 @@
<script lang="ts" setup>
import { VCard, IconUserSettings } from "@halo-dev/components";
import { inject, type Ref } from "vue";
import type { DashboardStats } from "@halo-dev/api-client";
const dashboardStats = inject<Ref<DashboardStats>>("dashboardStats");
</script>
<template>
<VCard class="h-full" :body-class="['h-full']">
<div class="flex h-full">
<div class="flex items-center gap-4">
<span
class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconUserSettings class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">用户</span>
<p class="text-2xl font-medium text-gray-900">
{{ dashboardStats?.users }}
</p>
</div>
</div>
</div>
</VCard>
</template>

View File

@ -23,6 +23,7 @@ module.exports = {
require("tailwindcss-safe-area"), require("tailwindcss-safe-area"),
require("@tailwindcss/aspect-ratio"), require("@tailwindcss/aspect-ratio"),
require("@formkit/themes/tailwindcss"), require("@formkit/themes/tailwindcss"),
require("@tailwindcss/container-queries"),
require("tailwindcss-themer")({ require("tailwindcss-themer")({
defaultTheme: { defaultTheme: {
extend: { extend: {

View File

@ -3,7 +3,6 @@ import fs from "fs";
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import Vue from "@vitejs/plugin-vue"; import Vue from "@vitejs/plugin-vue";
import VueJsx from "@vitejs/plugin-vue-jsx"; import VueJsx from "@vitejs/plugin-vue-jsx";
import VueSetupExtend from "vite-plugin-vue-setup-extend";
import Compression from "vite-compression-plugin"; import Compression from "vite-compression-plugin";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import Icons from "unplugin-icons/vite"; import Icons from "unplugin-icons/vite";
@ -12,7 +11,6 @@ import { setupLibraryExternal } from "./src/build/library-external";
export const sharedPlugins = [ export const sharedPlugins = [
Vue(), Vue(),
VueJsx(), VueJsx(),
VueSetupExtend(),
Compression(), Compression(),
Icons({ Icons({
compiler: "vue3", compiler: "vue3",