commit ca9430f82ce5773f877c07de21102b2ff6b844c8
Author: zhouhongshuo <409581486@qq.com>
Date: Sun Aug 25 23:46:05 2024 +0800
初始化
diff --git a/.changeset/README.md b/.changeset/README.md
new file mode 100644
index 0000000..e5b6d8d
--- /dev/null
+++ b/.changeset/README.md
@@ -0,0 +1,8 @@
+# Changesets
+
+Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
+with multi-package repos, or single-package repos to help you version and publish your code. You can
+find the full documentation for it [in our repository](https://github.com/changesets/changesets)
+
+We have a quick list of common questions to get you started engaging with this project in
+[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
diff --git a/.changeset/config.json b/.changeset/config.json
new file mode 100644
index 0000000..ff9fe79
--- /dev/null
+++ b/.changeset/config.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://unpkg.com/@changesets/config@2.0.1/schema.json",
+ "changelog": "@changesets/cli/changelog",
+ "commit": false,
+ "fixed": [],
+ "linked": [["@halo-dev/*"]],
+ "access": "public",
+ "baseBranch": "next",
+ "updateInternalDependencies": "patch",
+ "ignore": []
+}
diff --git a/.changeset/pre.json b/.changeset/pre.json
new file mode 100644
index 0000000..694d1d3
--- /dev/null
+++ b/.changeset/pre.json
@@ -0,0 +1,9 @@
+{
+ "mode": "pre",
+ "tag": "alpha",
+ "initialVersions": {
+ "@halo-dev/components": "0.0.0-alpha.0",
+ "@halo-dev/console-shared": "0.0.0-alpha.0"
+ },
+ "changesets": []
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..53fe054
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = false
+insert_final_newline = true
\ No newline at end of file
diff --git a/.env.development b/.env.development
new file mode 100644
index 0000000..e69de29
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..e69de29
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 0000000..63132e5
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,31 @@
+/* eslint-env node */
+require("@rushstack/eslint-patch/modern-module-resolution");
+
+module.exports = {
+ root: true,
+ extends: [
+ "plugin:vue/vue3-recommended",
+ "eslint:recommended",
+ "@vue/eslint-config-typescript/recommended",
+ "@vue/eslint-config-prettier",
+ ],
+ env: {
+ node: true,
+ "vue/setup-compiler-macros": true,
+ },
+ rules: {
+ "vue/multi-word-component-names": 0,
+ "@typescript-eslint/ban-ts-comment": 0,
+ "vue/no-v-html": 0,
+ },
+ overrides: [
+ {
+ files: ["cypress/integration/**.spec.{js,ts,jsx,tsx}"],
+ extends: ["plugin:cypress/recommended"],
+ },
+ ],
+ ignorePatterns: ["!.storybook", "packages/api-client"],
+ parserOptions: {
+ ecmaVersion: "latest",
+ },
+};
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b6c6ca7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+histoire-dist
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+!src/build
+storybook-static
diff --git a/.gitpod.yml b/.gitpod.yml
new file mode 100644
index 0000000..c7ed743
--- /dev/null
+++ b/.gitpod.yml
@@ -0,0 +1,15 @@
+ports:
+ - port: 3000
+ onOpen: open-browser
+
+tasks:
+ - init: npm install -g pnpm && pnpm install && pnpm build:packages
+ command: pnpm dev
+
+vscode:
+ extensions:
+ - dbaeumer.vscode-eslint
+ - editorconfig.editorconfig
+ - esbenp.prettier-vscode
+ - vue.volar
+ - vue.vscode-typescript-vue-plugin
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..f2c6343
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+cd ui && pnpm exec lint-staged
diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000..9357dbd
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1,4 @@
+/node_modules/*
+/.idea/*
+/.git/*
+/.github/*
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..19be10e
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+strict-peer-dependencies=false
+auto-install-peers=true
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..3909f45
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,2 @@
+pnpm-lock.yaml
+packages/api-client
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..80ef097
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,8 @@
+{
+ "recommendations": [
+ "dbaeumer.vscode-eslint",
+ "esbenp.prettier-vscode",
+ "editorconfig.editorconfig",
+ "vue.volar"
+ ]
+}
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f043af2
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,28 @@
+SHELL := /usr/bin/env bash -o errexit -o pipefail -o nounset
+
+install: ## Install console
+ pnpm install
+
+build-packages: install ## Build packages of console
+ pnpm build:packages
+
+build: build-packages ## Build console
+ pnpm build
+
+lint: build-packages ## Lint console
+ pnpm lint
+ pnpm typecheck
+
+test: build-packages ## Test console
+ pnpm test:unit
+
+check: lint test ## Check console
+
+dev: build-packages ## Run console with development environment
+ pnpm dev
+
+api-client-gen: install ## Generate API client
+ pnpm api-client:gen
+
+help: ## print this help
+ @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z0-9_-]+:.*?## / {gsub("\\\\n",sprintf("\n%22c",""), $$2);printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..66b425d
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,15 @@
+reviewers:
+- ruibaby
+- guqing
+- JohnNiang
+- lan-yonghui
+- wan92hen
+- QuentinHsu
+- Aanko
+- wzrove
+- LIlGG
+
+approvers:
+- ruibaby
+- guqing
+- JohnNiang
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a377363
--- /dev/null
+++ b/README.md
@@ -0,0 +1,49 @@
+## README
+
+
+
+
+
+
+
+> Halo 2.0 的管理端项目(原 halo-admin)
+
+
+
+
+
+
+
+
+
+------------------------------
+
+当前仓库已经将 `halo-admin` 改为了 `console`。但对于 Halo 1.x 版本,依旧保持 halo-admin 的概念。
+
+## 开发环境运行
+
+```bash
+npm install -g pnpm@9
+```
+
+```bash
+pnpm install
+```
+
+```bash
+pnpm build:packages
+```
+
+```bash
+pnpm dev
+```
+
+## 生产构建
+
+```bash
+pnpm build
+```
+
+## 状态
+
+
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..0e4a649
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,74 @@
+plugins {
+ id 'idea'
+ id 'com.github.node-gradle.node'
+ id 'org.openapi.generator' version '7.6.0'
+}
+
+idea {
+ module {
+ excludeDirs += file('node_modules/')
+ excludeDirs += file('packages').listFiles().collect {
+ file(it.path + '/node_modules/')
+ }
+ excludeDirs += file('packages').listFiles().collect {
+ file(it.path + '/dist/')
+ }
+ }
+}
+
+tasks.register('clean', Delete) {
+ delete layout.buildDirectory
+ delete fileTree('packages') {
+ include '*/dist/**'
+ }
+}
+
+tasks.register('build', PnpmTask) {
+ dependsOn tasks.named('check'), tasks.named('buildPackages')
+ pnpmCommand = ['run', 'build']
+ inputs.files(fileTree(layout.projectDirectory) {
+ include 'console-src/**', 'uc-src/**', 'src/**', 'public/**', '*.js', '*.json', '*.yaml', 'index.html'
+ exclude '**/node_modules/**', '**/build/**', '**/dist/**'
+ })
+ outputs.dir(layout.buildDirectory.dir('dist'))
+ configure {
+ shouldRunAfter tasks.named('clean')
+ }
+}
+
+tasks.register('buildPackages', PnpmTask) {
+ dependsOn tasks.named('pnpmInstall')
+ inputs.files(fileTree('packages') {
+ exclude '**/node_modules/**', '**/dist/**'
+ })
+ inputs.file('package.json')
+ pnpmCommand = ['run', 'build:packages']
+ outputs.files(fileTree('packages') {
+ include '*/dist/**'
+ })
+}
+
+tasks.register('test', PnpmTask) {
+ dependsOn tasks.named('buildPackages')
+ pnpmCommand = ['run', 'test:unit']
+ shouldRunAfter tasks.named('lint'), tasks.named('typecheck')
+}
+
+tasks.register('lint', PnpmTask) {
+ dependsOn tasks.named('buildPackages')
+ pnpmCommand = ['run', 'lint']
+}
+
+tasks.register('typecheck', PnpmTask) {
+ dependsOn tasks.named('buildPackages')
+ pnpmCommand = ['run', 'typecheck']
+}
+
+tasks.register('check') {
+ dependsOn tasks.named('lint'), tasks.named('typecheck'), tasks.named('test')
+}
+
+tasks.register('dev', PnpmTask) {
+ dependsOn tasks.named('buildPackages')
+ pnpmCommand = ['run', 'dev']
+}
diff --git a/console-src/App.vue b/console-src/App.vue
new file mode 100644
index 0000000..3fda3d1
--- /dev/null
+++ b/console-src/App.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/console-src/composables/use-content-snapshot.ts b/console-src/composables/use-content-snapshot.ts
new file mode 100644
index 0000000..dde6e4e
--- /dev/null
+++ b/console-src/composables/use-content-snapshot.ts
@@ -0,0 +1,33 @@
+import { coreApiClient } from "@halo-dev/api-client";
+import { nextTick, ref, watch, type Ref } from "vue";
+
+interface SnapshotContent {
+ version: Ref;
+ handleFetchSnapshot: () => Promise;
+}
+
+export function useContentSnapshot(
+ snapshotName: Ref
+): SnapshotContent {
+ const version = ref(0);
+ watch(snapshotName, () => {
+ nextTick(() => {
+ handleFetchSnapshot();
+ });
+ });
+
+ const handleFetchSnapshot = async () => {
+ if (!snapshotName.value) {
+ return;
+ }
+ const { data } = await coreApiClient.content.snapshot.getSnapshot({
+ name: snapshotName.value,
+ });
+ version.value = data.metadata.version || 0;
+ };
+
+ return {
+ version,
+ handleFetchSnapshot,
+ };
+}
diff --git a/console-src/composables/use-dashboard-stats.ts b/console-src/composables/use-dashboard-stats.ts
new file mode 100644
index 0000000..3d7d375
--- /dev/null
+++ b/console-src/composables/use-dashboard-stats.ts
@@ -0,0 +1,13 @@
+import { consoleApiClient } from "@halo-dev/api-client";
+import { useQuery } from "@tanstack/vue-query";
+
+export function useDashboardStats() {
+ const { data } = useQuery({
+ queryKey: ["dashboard-stats"],
+ queryFn: async () => {
+ const { data } = await consoleApiClient.system.getStats();
+ return data;
+ },
+ });
+ return { data };
+}
diff --git a/console-src/composables/use-entity-extension-points.ts b/console-src/composables/use-entity-extension-points.ts
new file mode 100644
index 0000000..164644d
--- /dev/null
+++ b/console-src/composables/use-entity-extension-points.ts
@@ -0,0 +1,47 @@
+import { usePluginModuleStore } from "@/stores/plugin";
+import type { EntityFieldItem, PluginModule } from "@halo-dev/console-shared";
+import { computed, onMounted, ref, type ComputedRef, type Ref } from "vue";
+
+export function useEntityFieldItemExtensionPoint(
+ extensionPointName: string,
+ entity: Ref,
+ presets: ComputedRef
+) {
+ const { pluginModules } = usePluginModuleStore();
+ const itemsFromPlugins = ref([]);
+
+ const allItems = computed(() => {
+ return [...presets.value, ...itemsFromPlugins.value];
+ });
+
+ onMounted(() => {
+ pluginModules.forEach((pluginModule: PluginModule) => {
+ const { extensionPoints } = pluginModule;
+ if (!extensionPoints?.[extensionPointName]) {
+ return;
+ }
+ const items = extensionPoints[extensionPointName](
+ entity
+ ) as EntityFieldItem[];
+ itemsFromPlugins.value.push(...items);
+ });
+ });
+
+ const startFields = computed(() => {
+ return allItems.value
+ .filter((item) => item.position === "start")
+ .sort((a, b) => {
+ return a.priority - b.priority;
+ });
+ });
+
+ const endFields = computed(() => {
+ return allItems.value
+ .filter((item) => item.position === "end")
+ .sort((a, b) => {
+ return a.priority - b.priority;
+ });
+ });
+
+ return { startFields, endFields };
+}
diff --git a/console-src/composables/use-global-info.ts b/console-src/composables/use-global-info.ts
new file mode 100644
index 0000000..e9dc2a0
--- /dev/null
+++ b/console-src/composables/use-global-info.ts
@@ -0,0 +1,20 @@
+import type { GlobalInfo } from "@/types";
+import { useQuery } from "@tanstack/vue-query";
+import axios from "axios";
+
+export function useGlobalInfoFetch() {
+ const { data } = useQuery({
+ queryKey: ["globalinfo"],
+ queryFn: async () => {
+ const { data } = await axios.get(`/actuator/globalinfo`, {
+ withCredentials: true,
+ });
+
+ return data;
+ },
+ });
+
+ return {
+ globalInfo: data,
+ };
+}
diff --git a/console-src/composables/use-operation-extension-points.ts b/console-src/composables/use-operation-extension-points.ts
new file mode 100644
index 0000000..d594b58
--- /dev/null
+++ b/console-src/composables/use-operation-extension-points.ts
@@ -0,0 +1,36 @@
+import { usePluginModuleStore } from "@/stores/plugin";
+import type { OperationItem, PluginModule } from "@halo-dev/console-shared";
+import { computed, onMounted, ref, type ComputedRef, type Ref } from "vue";
+
+export function useOperationItemExtensionPoint(
+ extensionPointName: string,
+ entity: Ref,
+ presets: ComputedRef[]>
+) {
+ const { pluginModules } = usePluginModuleStore();
+
+ const itemsFromPlugins = ref[]>([]);
+
+ onMounted(() => {
+ pluginModules.forEach((pluginModule: PluginModule) => {
+ const { extensionPoints } = pluginModule;
+ if (!extensionPoints?.[extensionPointName]) {
+ return;
+ }
+
+ const items = extensionPoints[extensionPointName](
+ entity
+ ) as OperationItem[];
+
+ itemsFromPlugins.value.push(...items);
+ });
+ });
+
+ const operationItems = computed(() => {
+ return [...presets.value, ...itemsFromPlugins.value].sort((a, b) => {
+ return a.priority - b.priority;
+ });
+ });
+
+ return { operationItems };
+}
diff --git a/console-src/composables/use-save-keybinding.ts b/console-src/composables/use-save-keybinding.ts
new file mode 100644
index 0000000..cdfb768
--- /dev/null
+++ b/console-src/composables/use-save-keybinding.ts
@@ -0,0 +1,21 @@
+import { isMac } from "@/utils/device";
+import { useEventListener } from "@vueuse/core";
+import { useDebounceFn } from "@vueuse/shared";
+import { nextTick } from "vue";
+
+export function useSaveKeybinding(fn: () => void) {
+ const debouncedFn = useDebounceFn(() => {
+ fn();
+ }, 300);
+
+ useEventListener(window, "keydown", (e: KeyboardEvent) => {
+ if (isMac ? e.metaKey : e.ctrlKey) {
+ if (e.key === "s") {
+ e.preventDefault();
+ nextTick(() => {
+ debouncedFn();
+ });
+ }
+ }
+ });
+}
diff --git a/console-src/composables/use-setting-form.ts b/console-src/composables/use-setting-form.ts
new file mode 100644
index 0000000..63ef153
--- /dev/null
+++ b/console-src/composables/use-setting-form.ts
@@ -0,0 +1,92 @@
+// core libs
+// types
+import { computed, ref, watch, type ComputedRef, type Ref } from "vue";
+
+// libs
+import type { FormKitSchemaCondition, FormKitSchemaNode } from "@formkit/core";
+import type { ConfigMap, Setting, SettingForm } from "@halo-dev/api-client";
+import { cloneDeep } from "lodash-es";
+
+interface useSettingFormConvertReturn {
+ formSchema: ComputedRef<
+ (FormKitSchemaCondition | FormKitSchemaNode)[] | undefined
+ >;
+ configMapFormData: Ref>>;
+ convertToSave: () => ConfigMap | undefined;
+}
+
+export function useSettingFormConvert(
+ setting: Ref,
+ configMap: Ref,
+ group: Ref
+): useSettingFormConvertReturn {
+ const configMapFormData = ref>>({});
+
+ const formSchema = computed(() => {
+ if (!setting.value) {
+ return;
+ }
+ const { forms } = setting.value.spec;
+ return forms.find((item) => item.group === group?.value)?.formSchema as (
+ | FormKitSchemaCondition
+ | FormKitSchemaNode
+ )[];
+ });
+
+ watch(
+ () => configMap.value,
+ () => {
+ const { forms } = setting.value?.spec || {};
+
+ forms?.forEach((form) => {
+ configMapFormData.value[form.group] = JSON.parse(
+ configMap.value?.data?.[form.group] || "{}"
+ );
+ });
+
+ Object.keys(configMap.value?.data || {}).forEach((key) => {
+ if (!forms?.find((item) => item.group === key)) {
+ configMapFormData.value[key] = JSON.parse(
+ configMap.value?.data?.[key] || "{}"
+ );
+ }
+ });
+ },
+ {
+ immediate: true,
+ }
+ );
+
+ function convertToSave() {
+ const configMapToUpdate = cloneDeep(configMap.value);
+
+ if (!configMapToUpdate) {
+ return;
+ }
+
+ const data: {
+ [key: string]: string;
+ } = {};
+
+ const { forms } = setting.value?.spec || {};
+
+ forms?.forEach((item: SettingForm) => {
+ data[item.group] = JSON.stringify(configMapFormData?.value?.[item.group]);
+ });
+
+ Object.keys(configMap.value?.data || {}).forEach((key) => {
+ if (!forms?.find((item) => item.group === key)) {
+ data[key] = configMap.value?.data?.[key] || "{}";
+ }
+ });
+
+ configMapToUpdate.data = data;
+ return configMapToUpdate;
+ }
+
+ return {
+ formSchema,
+ configMapFormData,
+ convertToSave,
+ };
+}
diff --git a/console-src/composables/use-slugify.ts b/console-src/composables/use-slugify.ts
new file mode 100644
index 0000000..b94c1b4
--- /dev/null
+++ b/console-src/composables/use-slugify.ts
@@ -0,0 +1,69 @@
+import { useGlobalInfoStore } from "@/stores/global-info";
+import { FormType } from "@/types/slug";
+import { randomUUID } from "@/utils/id";
+import ShortUniqueId from "short-unique-id";
+import { slugify } from "transliteration";
+import { watch, type Ref } from "vue";
+const uid = new ShortUniqueId();
+const Strategy = {
+ generateByTitle: (value: string) => {
+ if (!value) return "";
+ return slugify(value, { trim: true });
+ },
+ shortUUID: (value: string) => {
+ if (!value) return "";
+ return uid.randomUUID(8);
+ },
+ UUID: (value: string) => {
+ if (!value) return "";
+ return randomUUID();
+ },
+ timestamp: (value: string) => {
+ if (!value) return "";
+ return new Date().getTime().toString();
+ },
+};
+
+const onceList = ["shortUUID", "UUID", "timestamp"];
+
+export default function useSlugify(
+ source: Ref,
+ target: Ref,
+ auto: Ref,
+ formType: FormType
+) {
+ const handleGenerateSlug = (forceUpdate = false, formType: FormType) => {
+ const globalInfoStore = useGlobalInfoStore();
+ const mode = globalInfoStore.globalInfo?.postSlugGenerationStrategy;
+
+ if (!mode) {
+ return;
+ }
+ if (formType != FormType.POST) {
+ target.value = Strategy["generateByTitle"](source.value);
+ return;
+ }
+ if (forceUpdate) {
+ target.value = Strategy[mode](source.value);
+ return;
+ }
+ if (onceList.includes(mode) && target.value) return;
+ target.value = Strategy[mode](source.value);
+ };
+
+ watch(
+ () => source.value,
+ () => {
+ if (auto.value) {
+ handleGenerateSlug(false, formType);
+ }
+ },
+ {
+ immediate: true,
+ }
+ );
+
+ return {
+ handleGenerateSlug,
+ };
+}
diff --git a/console-src/layouts/BasicLayout.vue b/console-src/layouts/BasicLayout.vue
new file mode 100644
index 0000000..b1fc71b
--- /dev/null
+++ b/console-src/layouts/BasicLayout.vue
@@ -0,0 +1,352 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/layouts/BlankLayout.vue b/console-src/layouts/BlankLayout.vue
new file mode 100644
index 0000000..c098042
--- /dev/null
+++ b/console-src/layouts/BlankLayout.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/console-src/main.ts b/console-src/main.ts
new file mode 100644
index 0000000..5c5c7e5
--- /dev/null
+++ b/console-src/main.ts
@@ -0,0 +1,113 @@
+import { consoleApiClient } from "@halo-dev/api-client";
+import { createPinia } from "pinia";
+import type { DirectiveBinding } from "vue";
+import { createApp } from "vue";
+import App from "./App.vue";
+import router from "./router";
+// setup
+import { getBrowserLanguage, i18n, setupI18n } from "@/locales";
+import { setupComponents } from "@/setup/setupComponents";
+import "@/setup/setupStyles";
+// core modules
+import { setupApiClient } from "@/setup/setupApiClient";
+import { setupVueQuery } from "@/setup/setupVueQuery";
+import { useGlobalInfoStore } from "@/stores/global-info";
+import { useRoleStore } from "@/stores/role";
+import { useUserStore } from "@/stores/user";
+import { hasPermission } from "@/utils/permission";
+import {
+ setupCoreModules,
+ setupPluginModules,
+} from "@console/setup/setupModules";
+import { useSystemConfigMapStore } from "@console/stores/system-configmap";
+import { useThemeStore } from "@console/stores/theme";
+
+const app = createApp(App);
+
+setupComponents(app);
+setupI18n(app);
+setupVueQuery(app);
+setupApiClient();
+
+app.use(createPinia());
+
+async function loadUserPermissions() {
+ const { data: currentPermissions } =
+ await consoleApiClient.user.getPermissions({
+ name: "-",
+ });
+ const roleStore = useRoleStore();
+ roleStore.$patch({
+ permissions: currentPermissions,
+ });
+ app.directive(
+ "permission",
+ (el: HTMLElement, binding: DirectiveBinding) => {
+ const uiPermissions = Array.from(
+ currentPermissions.uiPermissions
+ );
+ const { value } = binding;
+ const { any, enable } = binding.modifiers;
+
+ if (hasPermission(uiPermissions, value, any)) {
+ return;
+ }
+
+ if (enable) {
+ //TODO
+ return;
+ }
+ el?.remove?.();
+ }
+ );
+}
+
+async function loadActivatedTheme() {
+ const themeStore = useThemeStore();
+ await themeStore.fetchActivatedTheme();
+}
+
+(async function () {
+ await initApp();
+})();
+
+async function initApp() {
+ try {
+ setupCoreModules(app);
+
+ const userStore = useUserStore();
+ await userStore.fetchCurrentUser();
+
+ // set locale
+ i18n.global.locale.value =
+ localStorage.getItem("locale") || getBrowserLanguage();
+
+ const globalInfoStore = useGlobalInfoStore();
+ await globalInfoStore.fetchGlobalInfo();
+
+ if (userStore.isAnonymous) {
+ return;
+ }
+
+ await loadUserPermissions();
+
+ try {
+ await setupPluginModules(app);
+ } catch (e) {
+ console.error("Failed to load plugins", e);
+ }
+
+ // load system configMap
+ const systemConfigMapStore = useSystemConfigMapStore();
+ await systemConfigMapStore.fetchSystemConfigMap();
+
+ if (globalInfoStore.globalInfo?.userInitialized) {
+ await loadActivatedTheme();
+ }
+ } catch (e) {
+ console.error(e);
+ } finally {
+ app.use(router);
+ app.mount("#app");
+ }
+}
diff --git a/console-src/modules/contents/attachments/AttachmentList.vue b/console-src/modules/contents/attachments/AttachmentList.vue
new file mode 100644
index 0000000..ddce83b
--- /dev/null
+++ b/console-src/modules/contents/attachments/AttachmentList.vue
@@ -0,0 +1,627 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.attachment.actions.storage_policies") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.upload") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+ {{
+ $t("core.attachment.operations.deselect_items.button")
+ }}
+
+
+
+ {{ $t("core.attachment.operations.move.button") }}
+
+
+
+ {{ group.spec.displayName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.attachment.empty.actions.upload") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ attachment.spec.displayName }}
+
+
+
+ {{ $t("core.common.status.deleting") }}...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentDetailModal.vue b/console-src/modules/contents/attachments/components/AttachmentDetailModal.vue
new file mode 100644
index 0000000..e9a401f
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentDetailModal.vue
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+ {{ $t("core.common.status.loading") }}...
+
+
+
+
+ {{ $t("core.common.status.loading_error") }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.attachment.detail_modal.preview.not_support") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.close_and_shortcut") }}
+
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentError.vue b/console-src/modules/contents/attachments/components/AttachmentError.vue
new file mode 100644
index 0000000..17e6cc6
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentError.vue
@@ -0,0 +1,7 @@
+
+
+
+ {{ $t("core.common.status.loading_error") }}
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentGroupBadge.vue b/console-src/modules/contents/attachments/components/AttachmentGroupBadge.vue
new file mode 100644
index 0000000..04c921d
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentGroupBadge.vue
@@ -0,0 +1,216 @@
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentGroupEditingModal.vue b/console-src/modules/contents/attachments/components/AttachmentGroupEditingModal.vue
new file mode 100644
index 0000000..dad1ed4
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentGroupEditingModal.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.cancel_and_shortcut") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentGroupList.vue b/console-src/modules/contents/attachments/components/AttachmentGroupList.vue
new file mode 100644
index 0000000..b8b01d3
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentGroupList.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentListItem.vue b/console-src/modules/contents/attachments/components/AttachmentListItem.vue
new file mode 100644
index 0000000..85f0b57
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentListItem.vue
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ attachment.spec.mediaType }}
+
+
+ {{ prettyBytes(attachment.spec.size || 0) }}
+
+
+
+
+
+
+
+
+
+
+ {{ attachment.spec.ownerName }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDatetime(attachment.metadata.creationTimestamp) }}
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentLoading.vue b/console-src/modules/contents/attachments/components/AttachmentLoading.vue
new file mode 100644
index 0000000..6fd1a05
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentLoading.vue
@@ -0,0 +1,7 @@
+
+
+
+ {{ $t("core.common.status.loading") }}...
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentPermalinkList.vue b/console-src/modules/contents/attachments/components/AttachmentPermalinkList.vue
new file mode 100644
index 0000000..1338f1c
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentPermalinkList.vue
@@ -0,0 +1,72 @@
+
+
+
+
+ -
+
+
+
+ {{ format.label }}
+
+
+ {{ format.value }}
+
+
+
+
+ {{ $t("core.common.buttons.copy") }}
+
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentPoliciesModal.vue b/console-src/modules/contents/attachments/components/AttachmentPoliciesModal.vue
new file mode 100644
index 0000000..3b4b5ae
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentPoliciesModal.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+
+ {{ policyTemplate.spec?.displayName }}
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+ {{ policyTemplate.spec?.displayName }}
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDatetime(policy.metadata.creationTimestamp) }}
+
+
+
+
+
+
+ {{ $t("core.common.buttons.edit") }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.close_and_shortcut") }}
+
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentPolicyBadge.vue b/console-src/modules/contents/attachments/components/AttachmentPolicyBadge.vue
new file mode 100644
index 0000000..eff608d
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentPolicyBadge.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentPolicyEditingModal.vue b/console-src/modules/contents/attachments/components/AttachmentPolicyEditingModal.vue
new file mode 100644
index 0000000..ec09872
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentPolicyEditingModal.vue
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.cancel_and_shortcut") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentSelectorModal.vue b/console-src/modules/contents/attachments/components/AttachmentSelectorModal.vue
new file mode 100644
index 0000000..b0ae3f8
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentSelectorModal.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.status.loading") }}
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.confirm") }}
+
+ {{
+ $t("core.attachment.select_modal.operations.select.result", {
+ count: confirmCountMessage,
+ })
+ }}
+
+
+
+ {{ $t("core.common.buttons.cancel") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/AttachmentUploadModal.vue b/console-src/modules/contents/attachments/components/AttachmentUploadModal.vue
new file mode 100644
index 0000000..6091ecd
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/AttachmentUploadModal.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+ {{ $t("core.attachment.upload_modal.filters.policy.label") }}
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+ {{ policyTemplate.spec?.displayName }}
+
+
+
+
+
+
+
+
+
+ {{ $t("core.attachment.upload_modal.filters.group.label") }}
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/components/selector-providers/CoreSelectorProvider.vue b/console-src/modules/contents/attachments/components/selector-providers/CoreSelectorProvider.vue
new file mode 100644
index 0000000..3c2b08f
--- /dev/null
+++ b/console-src/modules/contents/attachments/components/selector-providers/CoreSelectorProvider.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.upload") }}
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.attachment.empty.actions.upload") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.status.loading") }}...
+
+
+
+
+
+
+ {{ $t("core.common.status.loading_error") }}
+
+
+
+
+
+
+
+ {{ attachment.spec.displayName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/attachments/composables/use-attachment-group.ts b/console-src/modules/contents/attachments/composables/use-attachment-group.ts
new file mode 100644
index 0000000..118297b
--- /dev/null
+++ b/console-src/modules/contents/attachments/composables/use-attachment-group.ts
@@ -0,0 +1,37 @@
+import type { Group } from "@halo-dev/api-client";
+import { coreApiClient } from "@halo-dev/api-client";
+import { useQuery } from "@tanstack/vue-query";
+import type { Ref } from "vue";
+
+interface useFetchAttachmentGroupReturn {
+ groups: Ref;
+ isLoading: Ref;
+ handleFetchGroups: () => void;
+}
+
+export function useFetchAttachmentGroup(): useFetchAttachmentGroupReturn {
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ["attachment-groups"],
+ queryFn: async () => {
+ const { data } = await coreApiClient.storage.group.listGroup({
+ labelSelector: ["!halo.run/hidden"],
+ sort: ["metadata.creationTimestamp,asc"],
+ });
+
+ return data.items;
+ },
+ refetchInterval(data) {
+ const hasDeletingGroup = data?.some(
+ (group) => !!group.metadata.deletionTimestamp
+ );
+
+ return hasDeletingGroup ? 1000 : false;
+ },
+ });
+
+ return {
+ groups: data,
+ isLoading,
+ handleFetchGroups: refetch,
+ };
+}
diff --git a/console-src/modules/contents/attachments/composables/use-attachment-policy.ts b/console-src/modules/contents/attachments/composables/use-attachment-policy.ts
new file mode 100644
index 0000000..47c3215
--- /dev/null
+++ b/console-src/modules/contents/attachments/composables/use-attachment-policy.ts
@@ -0,0 +1,55 @@
+import type { Policy, PolicyTemplate } from "@halo-dev/api-client";
+import { coreApiClient } from "@halo-dev/api-client";
+import { useQuery } from "@tanstack/vue-query";
+import type { Ref } from "vue";
+
+interface useFetchAttachmentPolicyReturn {
+ policies: Ref;
+ isLoading: Ref;
+ handleFetchPolicies: () => void;
+}
+
+interface useFetchAttachmentPolicyTemplatesReturn {
+ policyTemplates: Ref;
+ isLoading: Ref;
+ handleFetchPolicyTemplates: () => void;
+}
+
+export function useFetchAttachmentPolicy(): useFetchAttachmentPolicyReturn {
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ["attachment-policies"],
+ queryFn: async () => {
+ const { data } = await coreApiClient.storage.policy.listPolicy();
+ return data.items;
+ },
+ refetchInterval(data) {
+ const hasDeletingPolicy = data?.some(
+ (policy) => !!policy.metadata.deletionTimestamp
+ );
+ return hasDeletingPolicy ? 1000 : false;
+ },
+ });
+
+ return {
+ policies: data,
+ isLoading,
+ handleFetchPolicies: refetch,
+ };
+}
+
+export function useFetchAttachmentPolicyTemplate(): useFetchAttachmentPolicyTemplatesReturn {
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ["attachment-policy-templates"],
+ queryFn: async () => {
+ const { data } =
+ await coreApiClient.storage.policyTemplate.listPolicyTemplate();
+ return data.items;
+ },
+ });
+
+ return {
+ policyTemplates: data,
+ isLoading,
+ handleFetchPolicyTemplates: refetch,
+ };
+}
diff --git a/console-src/modules/contents/attachments/composables/use-attachment.ts b/console-src/modules/contents/attachments/composables/use-attachment.ts
new file mode 100644
index 0000000..1a386fd
--- /dev/null
+++ b/console-src/modules/contents/attachments/composables/use-attachment.ts
@@ -0,0 +1,307 @@
+import { matchMediaType } from "@/utils/media-type";
+import type { Attachment } from "@halo-dev/api-client";
+import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
+import { Dialog, Toast } from "@halo-dev/components";
+import { useQuery } from "@tanstack/vue-query";
+import { useClipboard } from "@vueuse/core";
+import { computed, nextTick, ref, watch, type Ref } from "vue";
+import { useI18n } from "vue-i18n";
+
+interface useAttachmentControlReturn {
+ attachments: Ref;
+ isLoading: Ref;
+ isFetching: Ref;
+ selectedAttachment: Ref;
+ selectedAttachments: Ref>;
+ checkedAll: Ref;
+ total: Ref;
+ handleFetchAttachments: () => void;
+ handleSelectPrevious: () => void;
+ handleSelectNext: () => void;
+ handleDeleteInBatch: () => void;
+ handleCheckAll: (checkAll: boolean) => void;
+ handleSelect: (attachment: Attachment | undefined) => void;
+ isChecked: (attachment: Attachment) => boolean;
+ handleReset: () => void;
+}
+
+export function useAttachmentControl(filterOptions: {
+ policyName?: Ref;
+ groupName?: Ref;
+ user?: Ref;
+ accepts?: Ref;
+ keyword?: Ref;
+ sort?: Ref;
+ page: Ref;
+ size: Ref;
+}): useAttachmentControlReturn {
+ const { t } = useI18n();
+
+ const { user, policyName, groupName, keyword, sort, page, size, accepts } =
+ filterOptions;
+
+ const selectedAttachment = ref();
+ const selectedAttachments = ref>(new Set());
+ const checkedAll = ref(false);
+
+ const total = ref(0);
+ const hasPrevious = ref(false);
+ const hasNext = ref(false);
+
+ const { data, isLoading, isFetching, refetch } = useQuery({
+ queryKey: [
+ "attachments",
+ policyName,
+ keyword,
+ groupName,
+ user,
+ accepts,
+ page,
+ size,
+ sort,
+ ],
+ queryFn: async () => {
+ const isUnGrouped = groupName?.value === "ungrouped";
+
+ const fieldSelectorMap: Record = {
+ "spec.policyName": policyName?.value,
+ "spec.ownerName": user?.value,
+ "spec.groupName": isUnGrouped ? undefined : groupName?.value,
+ };
+
+ const fieldSelector = Object.entries(fieldSelectorMap)
+ .map(([key, value]) => {
+ if (value) {
+ return `${key}=${value}`;
+ }
+ })
+ .filter(Boolean) as string[];
+
+ const { data } =
+ await consoleApiClient.storage.attachment.searchAttachments({
+ fieldSelector,
+ page: page.value,
+ size: size.value,
+ ungrouped: isUnGrouped,
+ accepts: accepts?.value,
+ keyword: keyword?.value,
+ sort: [sort?.value as string].filter(Boolean),
+ });
+
+ total.value = data.total;
+ hasPrevious.value = data.hasPrevious;
+ hasNext.value = data.hasNext;
+
+ return data.items;
+ },
+ refetchInterval(data) {
+ const hasDeletingAttachment = data?.some(
+ (attachment) => !!attachment.metadata.deletionTimestamp
+ );
+ return hasDeletingAttachment ? 1000 : false;
+ },
+ });
+
+ const handleSelectPrevious = async () => {
+ if (!data.value) return;
+
+ const index = data.value?.findIndex(
+ (attachment) =>
+ attachment.metadata.name === selectedAttachment.value?.metadata.name
+ );
+
+ if (index === undefined) return;
+
+ if (index > 0) {
+ selectedAttachment.value = data.value[index - 1];
+ return;
+ }
+
+ if (index === 0 && hasPrevious.value) {
+ page.value--;
+ await nextTick();
+ await refetch();
+ selectedAttachment.value = data.value[data.value.length - 1];
+ }
+ };
+
+ const handleSelectNext = async () => {
+ if (!data.value) return;
+
+ const index = data.value?.findIndex(
+ (attachment) =>
+ attachment.metadata.name === selectedAttachment.value?.metadata.name
+ );
+
+ if (index === undefined) return;
+
+ if (index < data.value?.length - 1) {
+ selectedAttachment.value = data.value[index + 1];
+ return;
+ }
+
+ if (index === data.value.length - 1 && hasNext.value) {
+ page.value++;
+ await nextTick();
+ await refetch();
+ selectedAttachment.value = data.value[0];
+ }
+ };
+
+ const handleDeleteInBatch = () => {
+ Dialog.warning({
+ title: t("core.attachment.operations.delete_in_batch.title"),
+ description: t("core.common.dialog.descriptions.cannot_be_recovered"),
+ confirmType: "danger",
+ confirmText: t("core.common.buttons.confirm"),
+ cancelText: t("core.common.buttons.cancel"),
+ onConfirm: async () => {
+ try {
+ const promises = Array.from(selectedAttachments.value).map(
+ (attachment) => {
+ return coreApiClient.storage.attachment.deleteAttachment({
+ name: attachment.metadata.name,
+ });
+ }
+ );
+ await Promise.all(promises);
+ selectedAttachments.value.clear();
+
+ Toast.success(t("core.common.toast.delete_success"));
+ } catch (e) {
+ console.error("Failed to delete attachments", e);
+ } finally {
+ await refetch();
+ }
+ },
+ });
+ };
+
+ const handleCheckAll = (checkAll: boolean) => {
+ if (checkAll) {
+ data.value?.forEach((attachment) => {
+ selectedAttachments.value.add(attachment);
+ });
+ } else {
+ selectedAttachments.value.clear();
+ }
+ };
+
+ const handleSelect = async (attachment: Attachment | undefined) => {
+ if (!attachment) return;
+ if (selectedAttachments.value.has(attachment)) {
+ selectedAttachments.value.delete(attachment);
+ return;
+ }
+ selectedAttachments.value.add(attachment);
+ };
+
+ watch(
+ () => selectedAttachments.value.size,
+ (newValue) => {
+ checkedAll.value = newValue === data.value?.length;
+ }
+ );
+
+ const isChecked = (attachment: Attachment) => {
+ return (
+ attachment.metadata.name === selectedAttachment.value?.metadata.name ||
+ Array.from(selectedAttachments.value)
+ .map((item) => item.metadata.name)
+ .includes(attachment.metadata.name)
+ );
+ };
+
+ const handleReset = () => {
+ page.value = 1;
+ selectedAttachment.value = undefined;
+ selectedAttachments.value.clear();
+ checkedAll.value = false;
+ };
+
+ return {
+ attachments: data,
+ isLoading,
+ isFetching,
+ selectedAttachment,
+ selectedAttachments,
+ checkedAll,
+ total,
+ handleFetchAttachments: refetch,
+ handleSelectPrevious,
+ handleSelectNext,
+ handleDeleteInBatch,
+ handleCheckAll,
+ handleSelect,
+ isChecked,
+ handleReset,
+ };
+}
+
+export function useAttachmentPermalinkCopy(
+ attachment: Ref
+) {
+ const { copy } = useClipboard({ legacy: true });
+ const { t } = useI18n();
+
+ const mediaType = computed(() => {
+ return attachment.value?.spec.mediaType;
+ });
+
+ const isImage = computed(() => {
+ return mediaType.value && matchMediaType(mediaType.value, "image/*");
+ });
+
+ const isVideo = computed(() => {
+ return mediaType.value && matchMediaType(mediaType.value, "video/*");
+ });
+
+ const isAudio = computed(() => {
+ return mediaType.value && matchMediaType(mediaType.value, "audio/*");
+ });
+
+ const htmlText = computed(() => {
+ const { permalink } = attachment.value?.status || {};
+ const { displayName } = attachment.value?.spec || {};
+
+ if (isImage.value) {
+ return `
`;
+ } else if (isVideo.value) {
+ return ``;
+ } else if (isAudio.value) {
+ return ``;
+ }
+ return `${displayName}`;
+ });
+
+ const markdownText = computed(() => {
+ const { permalink } = attachment.value?.status || {};
+ const { displayName } = attachment.value?.spec || {};
+ if (isImage.value) {
+ return ``;
+ }
+ return `[${displayName}](${permalink})`;
+ });
+
+ const handleCopy = (format: "markdown" | "html" | "url") => {
+ const { permalink } = attachment.value?.status || {};
+
+ if (!permalink) return;
+
+ if (format === "url") {
+ copy(permalink);
+ } else if (format === "markdown") {
+ copy(markdownText.value);
+ } else if (format === "html") {
+ copy(htmlText.value);
+ }
+
+ Toast.success(t("core.common.toast.copy_success"));
+ };
+
+ return {
+ htmlText,
+ markdownText,
+ handleCopy,
+ };
+}
diff --git a/console-src/modules/contents/attachments/module.ts b/console-src/modules/contents/attachments/module.ts
new file mode 100644
index 0000000..1ef394b
--- /dev/null
+++ b/console-src/modules/contents/attachments/module.ts
@@ -0,0 +1,37 @@
+import BasicLayout from "@console/layouts/BasicLayout.vue";
+import { IconFolder } from "@halo-dev/components";
+import { definePlugin } from "@halo-dev/console-shared";
+import { markRaw } from "vue";
+import AttachmentList from "./AttachmentList.vue";
+import AttachmentSelectorModal from "./components/AttachmentSelectorModal.vue";
+
+export default definePlugin({
+ components: {
+ AttachmentSelectorModal,
+ },
+ routes: [
+ {
+ path: "/attachments",
+ name: "AttachmentsRoot",
+ component: BasicLayout,
+ meta: {
+ title: "core.attachment.title",
+ permissions: ["system:attachments:view"],
+ menu: {
+ name: "core.sidebar.menu.items.attachments",
+ group: "content",
+ icon: markRaw(IconFolder),
+ priority: 3,
+ mobile: true,
+ },
+ },
+ children: [
+ {
+ path: "",
+ name: "Attachments",
+ component: AttachmentList,
+ },
+ ],
+ },
+ ],
+});
diff --git a/console-src/modules/contents/comments/CommentList.vue b/console-src/modules/contents/comments/CommentList.vue
new file mode 100644
index 0000000..047be82
--- /dev/null
+++ b/console-src/modules/contents/comments/CommentList.vue
@@ -0,0 +1,413 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t(
+ "core.comment.operations.approve_comment_in_batch.button"
+ )
+ }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/comments/components/CommentListItem.vue b/console-src/modules/contents/comments/components/CommentListItem.vue
new file mode 100644
index 0000000..6927cac
--- /dev/null
+++ b/console-src/modules/contents/comments/components/CommentListItem.vue
@@ -0,0 +1,508 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ subjectRefResult.label }}
+
+ {{ subjectRefResult.title }}
+
+
+
+
+
+
+ {{ comment?.comment?.spec.content }}
+
+
+
+ {{
+ $t("core.comment.list.fields.reply_count", {
+ count: comment?.comment?.status?.replyCount || 0,
+ })
+ }}
+
+
+
+ {{ $t("core.comment.operations.reply.button") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.comment.list.fields.pending_review") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ formatDatetime(
+ comment?.comment.spec.creationTime ||
+ comment?.comment.metadata.creationTimestamp
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.comment.reply_empty.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/comments/components/ReplyCreationModal.vue b/console-src/modules/contents/comments/components/ReplyCreationModal.vue
new file mode 100644
index 0000000..06842c8
--- /dev/null
+++ b/console-src/modules/contents/comments/components/ReplyCreationModal.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.cancel_and_shortcut") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/comments/components/ReplyListItem.vue b/console-src/modules/contents/comments/components/ReplyListItem.vue
new file mode 100644
index 0000000..9ea0a7e
--- /dev/null
+++ b/console-src/modules/contents/comments/components/ReplyListItem.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.comment.list.fields.pending_review") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ formatDatetime(
+ reply?.reply?.spec.creationTime ||
+ reply?.reply.metadata.creationTimestamp
+ )
+ }}
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/comments/module.ts b/console-src/modules/contents/comments/module.ts
new file mode 100644
index 0000000..eee1b12
--- /dev/null
+++ b/console-src/modules/contents/comments/module.ts
@@ -0,0 +1,38 @@
+import BasicLayout from "@console/layouts/BasicLayout.vue";
+import { IconMessage } from "@halo-dev/components";
+import { definePlugin } from "@halo-dev/console-shared";
+import { markRaw } from "vue";
+import CommentList from "./CommentList.vue";
+import CommentStatsWidget from "./widgets/CommentStatsWidget.vue";
+
+export default definePlugin({
+ components: {
+ CommentStatsWidget,
+ },
+ routes: [
+ {
+ path: "/comments",
+ name: "CommentsRoot",
+ component: BasicLayout,
+ meta: {
+ title: "core.comment.title",
+ searchable: true,
+ permissions: ["system:comments:view"],
+ menu: {
+ name: "core.sidebar.menu.items.comments",
+ group: "content",
+ icon: markRaw(IconMessage),
+ priority: 2,
+ mobile: true,
+ },
+ },
+ children: [
+ {
+ path: "",
+ name: "Comments",
+ component: CommentList,
+ },
+ ],
+ },
+ ],
+});
diff --git a/console-src/modules/contents/comments/widgets/CommentStatsWidget.vue b/console-src/modules/contents/comments/widgets/CommentStatsWidget.vue
new file mode 100644
index 0000000..ef7afbb
--- /dev/null
+++ b/console-src/modules/contents/comments/widgets/CommentStatsWidget.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.dashboard.widgets.presets.comment_stats.title") }}
+
+
+ {{ stats?.approvedComments }}
+
+
+
+
+
+
diff --git a/console-src/modules/contents/pages/DeletedSinglePageList.vue b/console-src/modules/contents/pages/DeletedSinglePageList.vue
new file mode 100644
index 0000000..90a9a05
--- /dev/null
+++ b/console-src/modules/contents/pages/DeletedSinglePageList.vue
@@ -0,0 +1,402 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.back") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.delete_permanently") }}
+
+
+ {{ $t("core.common.buttons.recovery") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+ {{ $t("core.common.buttons.back") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/pages/SinglePageEditor.vue b/console-src/modules/contents/pages/SinglePageEditor.vue
new file mode 100644
index 0000000..6287f2e
--- /dev/null
+++ b/console-src/modules/contents/pages/SinglePageEditor.vue
@@ -0,0 +1,546 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.page_editor.actions.snapshots") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.preview") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.save") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.setting") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.publish") }}
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/pages/SinglePageList.vue b/console-src/modules/contents/pages/SinglePageList.vue
new file mode 100644
index 0000000..77b25c0
--- /dev/null
+++ b/console-src/modules/contents/pages/SinglePageList.vue
@@ -0,0 +1,479 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.page.actions.recycle_bin") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/pages/SinglePageSnapshots.vue b/console-src/modules/contents/pages/SinglePageSnapshots.vue
new file mode 100644
index 0000000..3db7fd4
--- /dev/null
+++ b/console-src/modules/contents/pages/SinglePageSnapshots.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.back") }}
+
+
+ {{ $t("core.page_snapshots.operations.cleanup.button") }}
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/pages/components/SinglePageListItem.vue b/console-src/modules/contents/pages/components/SinglePageListItem.vue
new file mode 100644
index 0000000..c6e5bf4
--- /dev/null
+++ b/console-src/modules/contents/pages/components/SinglePageListItem.vue
@@ -0,0 +1,258 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t("core.page.list.fields.visits", {
+ visits: singlePage.stats.visit || 0,
+ })
+ }}
+
+
+ {{
+ $t("core.page.list.fields.comments", {
+ comments: singlePage.stats.totalComment || 0,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDatetime(singlePage.page.spec.publishTime) }}
+
+
+
+
+
+
+ {{ $t("core.common.buttons.edit") }}
+
+
+ {{ $t("core.common.buttons.setting") }}
+
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
diff --git a/console-src/modules/contents/pages/components/SinglePageSettingModal.vue b/console-src/modules/contents/pages/components/SinglePageSettingModal.vue
new file mode 100644
index 0000000..97a1043
--- /dev/null
+++ b/console-src/modules/contents/pages/components/SinglePageSettingModal.vue
@@ -0,0 +1,490 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.page.settings.groups.general") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.page.settings.groups.advanced") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.page.settings.groups.annotations") }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.publish") }}
+
+
+ {{ $t("core.common.buttons.save") }}
+
+
+ {{ $t("core.common.buttons.close") }}
+
+
+
+
+ {{ $t("core.common.buttons.cancel_publish") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/pages/components/SnapshotContent.vue b/console-src/modules/contents/pages/components/SnapshotContent.vue
new file mode 100644
index 0000000..bd06e2a
--- /dev/null
+++ b/console-src/modules/contents/pages/components/SnapshotContent.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/pages/components/SnapshotListItem.vue b/console-src/modules/contents/pages/components/SnapshotListItem.vue
new file mode 100644
index 0000000..6d87275
--- /dev/null
+++ b/console-src/modules/contents/pages/components/SnapshotListItem.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+ {{ relativeTimeTo(snapshot.metadata.creationTimestamp) }}
+
+
+
+ {{ $t("core.page_snapshots.status.released") }}
+
+
+ {{ $t("core.page_snapshots.status.draft") }}
+
+
+ {{ $t("core.page_snapshots.status.base") }}
+
+
+
+
+
+
+ {{ snapshot.spec.owner }}
+
+
+
+ {{ $t("core.page_snapshots.operations.revert.button") }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/pages/composables/use-page-update-mutate.ts b/console-src/modules/contents/pages/composables/use-page-update-mutate.ts
new file mode 100644
index 0000000..2cb219d
--- /dev/null
+++ b/console-src/modules/contents/pages/composables/use-page-update-mutate.ts
@@ -0,0 +1,40 @@
+import type { SinglePage } from "@halo-dev/api-client";
+import { coreApiClient } from "@halo-dev/api-client";
+import { Toast } from "@halo-dev/components";
+import { useMutation } from "@tanstack/vue-query";
+import { useI18n } from "vue-i18n";
+
+export function usePageUpdateMutate() {
+ const { t } = useI18n();
+ return useMutation({
+ mutationKey: ["singlePage-update"],
+ mutationFn: async (page: SinglePage) => {
+ const { data: latestSinglePage } =
+ await coreApiClient.content.singlePage.getSinglePage({
+ name: page.metadata.name,
+ });
+
+ return coreApiClient.content.singlePage.updateSinglePage(
+ {
+ name: page.metadata.name,
+ singlePage: {
+ ...latestSinglePage,
+ spec: page.spec,
+ metadata: {
+ ...latestSinglePage.metadata,
+ annotations: page.metadata.annotations,
+ },
+ },
+ },
+ {
+ mute: true,
+ }
+ );
+ },
+ retry: 3,
+ onError: (error) => {
+ console.error("Failed to update singlePage", error);
+ Toast.error(t("core.common.toast.server_internal_error"));
+ },
+ });
+}
diff --git a/console-src/modules/contents/pages/module.ts b/console-src/modules/contents/pages/module.ts
new file mode 100644
index 0000000..1c7ab04
--- /dev/null
+++ b/console-src/modules/contents/pages/module.ts
@@ -0,0 +1,72 @@
+import BasicLayout from "@console/layouts/BasicLayout.vue";
+import { IconPages } from "@halo-dev/components";
+import { definePlugin } from "@halo-dev/console-shared";
+import { markRaw } from "vue";
+import DeletedSinglePageList from "./DeletedSinglePageList.vue";
+import SinglePageEditor from "./SinglePageEditor.vue";
+import SinglePageList from "./SinglePageList.vue";
+import SinglePageSnapshots from "./SinglePageSnapshots.vue";
+import SinglePageStatsWidget from "./widgets/SinglePageStatsWidget.vue";
+
+export default definePlugin({
+ components: {
+ SinglePageStatsWidget,
+ },
+ routes: [
+ {
+ path: "/single-pages",
+ name: "SinglePagesRoot",
+ component: BasicLayout,
+ meta: {
+ title: "core.page.title",
+ searchable: true,
+ permissions: ["system:singlepages:view"],
+ menu: {
+ name: "core.sidebar.menu.items.single_pages",
+ group: "content",
+ icon: markRaw(IconPages),
+ priority: 1,
+ },
+ },
+ children: [
+ {
+ path: "",
+ name: "SinglePages",
+ component: SinglePageList,
+ },
+ {
+ path: "deleted",
+ name: "DeletedSinglePages",
+ component: DeletedSinglePageList,
+ meta: {
+ title: "core.deleted_page.title",
+ searchable: true,
+ permissions: ["system:singlepages:view"],
+ },
+ },
+ {
+ path: "editor",
+ name: "SinglePageEditor",
+ component: SinglePageEditor,
+ meta: {
+ title: "core.page_editor.title",
+ searchable: true,
+ hideFooter: true,
+ permissions: ["system:singlepages:manage"],
+ },
+ },
+ {
+ path: "snapshots",
+ name: "SinglePageSnapshots",
+ component: SinglePageSnapshots,
+ meta: {
+ title: "core.page_snapshots.title",
+ searchable: false,
+ hideFooter: true,
+ permissions: ["system:singlepages:manage"],
+ },
+ },
+ ],
+ },
+ ],
+});
diff --git a/console-src/modules/contents/pages/widgets/SinglePageStatsWidget.vue b/console-src/modules/contents/pages/widgets/SinglePageStatsWidget.vue
new file mode 100644
index 0000000..46dae2b
--- /dev/null
+++ b/console-src/modules/contents/pages/widgets/SinglePageStatsWidget.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.dashboard.widgets.presets.page_stats.title") }}
+
+
+ {{ total || 0 }}
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/DeletedPostList.vue b/console-src/modules/contents/posts/DeletedPostList.vue
new file mode 100644
index 0000000..29e50b2
--- /dev/null
+++ b/console-src/modules/contents/posts/DeletedPostList.vue
@@ -0,0 +1,423 @@
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.back") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.delete_permanently") }}
+
+
+ {{ $t("core.common.buttons.recovery") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+ {{ $t("core.common.buttons.back") }}
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post.list.fields.categories") }}
+
+ {{ category.spec.displayName }}
+
+
+
+ {{
+ $t("core.post.list.fields.visits", {
+ visits: post.stats.visit,
+ })
+ }}
+
+
+ {{
+ $t("core.post.list.fields.comments", {
+ comments: post.stats.totalComment || 0,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDatetime(post.post.spec.publishTime) }}
+
+
+
+
+
+
+ {{ $t("core.common.buttons.delete_permanently") }}
+
+
+ {{ $t("core.common.buttons.recovery") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/PostEditor.vue b/console-src/modules/contents/posts/PostEditor.vue
new file mode 100644
index 0000000..f55527c
--- /dev/null
+++ b/console-src/modules/contents/posts/PostEditor.vue
@@ -0,0 +1,582 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post_editor.actions.snapshots") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.preview") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.save") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.setting") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.publish") }}
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/PostList.vue b/console-src/modules/contents/posts/PostList.vue
new file mode 100644
index 0000000..01c36fa
--- /dev/null
+++ b/console-src/modules/contents/posts/PostList.vue
@@ -0,0 +1,607 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post.actions.categories") }}
+
+
+ {{ $t("core.post.actions.tags") }}
+
+
+ {{ $t("core.post.actions.recycle_bin") }}
+
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.publish") }}
+
+
+ {{ $t("core.common.buttons.cancel_publish") }}
+
+
+ {{ $t("core.post.operations.batch_setting.button") }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/PostSnapshots.vue b/console-src/modules/contents/posts/PostSnapshots.vue
new file mode 100644
index 0000000..c6478bf
--- /dev/null
+++ b/console-src/modules/contents/posts/PostSnapshots.vue
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.back") }}
+
+
+ {{ $t("core.post_snapshots.operations.cleanup.button") }}
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/categories/CategoryList.vue b/console-src/modules/contents/posts/categories/CategoryList.vue
new file mode 100644
index 0000000..43ba0e8
--- /dev/null
+++ b/console-src/modules/contents/posts/categories/CategoryList.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t("core.post_category.header.title", {
+ count: categories?.length || 0,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue b/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue
new file mode 100644
index 0000000..6067169
--- /dev/null
+++ b/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue
@@ -0,0 +1,381 @@
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post_category.editing_modal.groups.general") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post_category.editing_modal.groups.annotations") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.save_and_continue") }}
+
+
+
+ {{ $t("core.common.buttons.cancel_and_shortcut") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/categories/components/CategoryListItem.vue b/console-src/modules/contents/posts/categories/components/CategoryListItem.vue
new file mode 100644
index 0000000..9c02542
--- /dev/null
+++ b/console-src/modules/contents/posts/categories/components/CategoryListItem.vue
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ category.status.permalink }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDatetime(category.metadata.creationTimestamp) }}
+
+
+
+
+
+
+ {{ $t("core.common.buttons.edit") }}
+
+
+ {{ $t("core.post_category.operations.add_sub_category.button") }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/categories/components/__tests__/CategoryEditingModal.spec.ts b/console-src/modules/contents/posts/categories/components/__tests__/CategoryEditingModal.spec.ts
new file mode 100644
index 0000000..2f856cb
--- /dev/null
+++ b/console-src/modules/contents/posts/categories/components/__tests__/CategoryEditingModal.spec.ts
@@ -0,0 +1,28 @@
+import messages from "@intlify/unplugin-vue-i18n/messages";
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import { createI18n } from "vue-i18n";
+import CategoryEditingModal from "../CategoryEditingModal.vue";
+
+describe("CategoryEditingModal", function () {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
+ it("should render", function () {
+ expect(
+ mount(CategoryEditingModal, {
+ global: {
+ plugins: [
+ createI18n({
+ legacy: false,
+ locale: "en",
+ messages,
+ }),
+ ],
+ },
+ })
+ ).toBeDefined();
+ });
+});
diff --git a/console-src/modules/contents/posts/categories/composables/use-post-category.ts b/console-src/modules/contents/posts/categories/composables/use-post-category.ts
new file mode 100644
index 0000000..eaf6a6d
--- /dev/null
+++ b/console-src/modules/contents/posts/categories/composables/use-post-category.ts
@@ -0,0 +1,52 @@
+import type { Category } from "@halo-dev/api-client";
+import { coreApiClient } from "@halo-dev/api-client";
+import { useQuery } from "@tanstack/vue-query";
+import type { Ref } from "vue";
+import { ref } from "vue";
+import type { CategoryTree } from "../utils";
+import { buildCategoriesTree } from "../utils";
+
+interface usePostCategoryReturn {
+ categories: Ref;
+ categoriesTree: Ref;
+ isLoading: Ref;
+ handleFetchCategories: () => void;
+}
+
+export function usePostCategory(): usePostCategoryReturn {
+ const categoriesTree = ref([] as CategoryTree[]);
+
+ const {
+ data: categories,
+ isLoading,
+ refetch,
+ } = useQuery({
+ queryKey: ["post-categories"],
+ queryFn: async () => {
+ const { data } = await coreApiClient.content.category.listCategory({
+ page: 0,
+ size: 0,
+ sort: ["metadata.creationTimestamp,desc"],
+ });
+
+ return data.items;
+ },
+ refetchInterval(data) {
+ const hasAbnormalCategory = data?.some(
+ (category) =>
+ !!category.metadata.deletionTimestamp || !category.status?.permalink
+ );
+ return hasAbnormalCategory ? 1000 : false;
+ },
+ onSuccess(data) {
+ categoriesTree.value = buildCategoriesTree(data);
+ },
+ });
+
+ return {
+ categories,
+ categoriesTree,
+ isLoading,
+ handleFetchCategories: refetch,
+ };
+}
diff --git a/console-src/modules/contents/posts/categories/utils/index.ts b/console-src/modules/contents/posts/categories/utils/index.ts
new file mode 100644
index 0000000..dc5863d
--- /dev/null
+++ b/console-src/modules/contents/posts/categories/utils/index.ts
@@ -0,0 +1,146 @@
+import type { Category, CategorySpec } from "@halo-dev/api-client";
+import { cloneDeep } from "lodash-es";
+
+export interface CategoryTreeSpec extends Omit {
+ children: CategoryTree[];
+}
+
+export interface CategoryTree extends Omit {
+ spec: CategoryTreeSpec;
+}
+
+export function buildCategoriesTree(categories: Category[]): CategoryTree[] {
+ const categoriesToUpdate = cloneDeep(categories);
+
+ const categoriesMap = {};
+ const parentMap = {};
+
+ categoriesToUpdate.forEach((category) => {
+ categoriesMap[category.metadata.name] = category;
+ // @ts-ignore
+ category.spec.children.forEach((child) => {
+ parentMap[child] = category.metadata.name;
+ });
+ // @ts-ignore
+ category.spec.children = [];
+ });
+
+ categoriesToUpdate.forEach((category) => {
+ const parentName = parentMap[category.metadata.name];
+ if (parentName && categoriesMap[parentName]) {
+ categoriesMap[parentName].spec.children.push(category);
+ }
+ });
+
+ const categoriesTree = categoriesToUpdate.filter(
+ (node) => parentMap[node.metadata.name] === undefined
+ );
+
+ return sortCategoriesTree(categoriesTree);
+}
+
+export function sortCategoriesTree(
+ categoriesTree: CategoryTree[] | Category[]
+): CategoryTree[] {
+ return categoriesTree
+ .sort((a, b) => {
+ if (a.spec.priority < b.spec.priority) {
+ return -1;
+ }
+ if (a.spec.priority > b.spec.priority) {
+ return 1;
+ }
+ return 0;
+ })
+ .map((category) => {
+ if (category.spec.children.length) {
+ return {
+ ...category,
+ spec: {
+ ...category.spec,
+ children: sortCategoriesTree(category.spec.children),
+ },
+ };
+ }
+ return category;
+ });
+}
+
+export function resetCategoriesTreePriority(
+ categoriesTree: CategoryTree[]
+): CategoryTree[] {
+ for (let i = 0; i < categoriesTree.length; i++) {
+ categoriesTree[i].spec.priority = i;
+ if (categoriesTree[i].spec.children) {
+ resetCategoriesTreePriority(categoriesTree[i].spec.children);
+ }
+ }
+ return categoriesTree;
+}
+
+export function convertTreeToCategories(categoriesTree: CategoryTree[]) {
+ const categories: Category[] = [];
+ const categoriesMap = new Map();
+ const convertCategory = (node: CategoryTree | undefined) => {
+ if (!node) {
+ return;
+ }
+ const children = node.spec.children || [];
+ categoriesMap.set(node.metadata.name, {
+ ...node,
+ spec: {
+ ...node.spec,
+ // @ts-ignore
+ children: children.map((child) => child.metadata.name),
+ },
+ });
+ children.forEach((child) => {
+ convertCategory(child);
+ });
+ };
+ categoriesTree.forEach((node) => {
+ convertCategory(node);
+ });
+ categoriesMap.forEach((node) => {
+ categories.push(node);
+ });
+ return categories;
+}
+
+export function convertCategoryTreeToCategory(
+ categoryTree: CategoryTree
+): Category {
+ const childNames = categoryTree.spec.children.map(
+ (child) => child.metadata.name
+ );
+ return {
+ ...categoryTree,
+ spec: {
+ ...categoryTree.spec,
+ children: childNames,
+ },
+ };
+}
+
+export const getCategoryPath = (
+ categories: CategoryTree[],
+ name: string,
+ path: CategoryTree[] = []
+): CategoryTree[] | undefined => {
+ for (const category of categories) {
+ if (category.metadata && category.metadata.name === name) {
+ return path.concat([category]);
+ }
+
+ if (category.spec && category.spec.children) {
+ const found = getCategoryPath(
+ category.spec.children,
+ name,
+ path.concat([category])
+ );
+ if (found) {
+ return found;
+ }
+ }
+ }
+};
diff --git a/console-src/modules/contents/posts/components/PostBatchSettingModal.vue b/console-src/modules/contents/posts/components/PostBatchSettingModal.vue
new file mode 100644
index 0000000..1b68e24
--- /dev/null
+++ b/console-src/modules/contents/posts/components/PostBatchSettingModal.vue
@@ -0,0 +1,338 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.save") }}
+
+
+ {{ $t("core.common.buttons.cancel") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/PostListItem.vue b/console-src/modules/contents/posts/components/PostListItem.vue
new file mode 100644
index 0000000..01796f8
--- /dev/null
+++ b/console-src/modules/contents/posts/components/PostListItem.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/PostSettingModal.vue b/console-src/modules/contents/posts/components/PostSettingModal.vue
new file mode 100644
index 0000000..99bc246
--- /dev/null
+++ b/console-src/modules/contents/posts/components/PostSettingModal.vue
@@ -0,0 +1,520 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post.settings.groups.general") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post.settings.groups.advanced") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post.settings.groups.annotations") }}
+
+
+
+
+
+
+
+
+
+
+ {{
+ isScheduledPublish
+ ? $t("core.common.buttons.schedule_publish")
+ : $t("core.common.buttons.publish")
+ }}
+
+
+ {{ $t("core.common.buttons.save") }}
+
+
+ {{ $t("core.common.buttons.close") }}
+
+
+
+
+ {{ $t("core.common.buttons.cancel_publish") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/SnapshotContent.vue b/console-src/modules/contents/posts/components/SnapshotContent.vue
new file mode 100644
index 0000000..e8566d9
--- /dev/null
+++ b/console-src/modules/contents/posts/components/SnapshotContent.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/SnapshotListItem.vue b/console-src/modules/contents/posts/components/SnapshotListItem.vue
new file mode 100644
index 0000000..fb41df4
--- /dev/null
+++ b/console-src/modules/contents/posts/components/SnapshotListItem.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+ {{ relativeTimeTo(snapshot.metadata.creationTimestamp) }}
+
+
+
+ {{ $t("core.post_snapshots.status.released") }}
+
+
+ {{ $t("core.post_snapshots.status.draft") }}
+
+
+ {{ $t("core.post_snapshots.status.base") }}
+
+
+
+
+
+
+ {{ snapshot.spec.owner }}
+
+
+
+ {{ $t("core.post_snapshots.operations.revert.button") }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/__tests__/PostSettingModal.spec.ts b/console-src/modules/contents/posts/components/__tests__/PostSettingModal.spec.ts
new file mode 100644
index 0000000..3e674d1
--- /dev/null
+++ b/console-src/modules/contents/posts/components/__tests__/PostSettingModal.spec.ts
@@ -0,0 +1,37 @@
+import messages from "@intlify/unplugin-vue-i18n/messages";
+import { VueQueryPlugin } from "@tanstack/vue-query";
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import { createI18n } from "vue-i18n";
+import PostSettingModal from "../PostSettingModal.vue";
+
+describe("PostSettingModal", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
+ it("should render", () => {
+ const wrapper = mount(
+ {
+ components: {
+ PostSettingModal,
+ },
+ template: ``,
+ },
+ {
+ global: {
+ plugins: [
+ VueQueryPlugin,
+ createI18n({
+ legacy: false,
+ locale: "en",
+ messages,
+ }),
+ ],
+ },
+ }
+ );
+ expect(wrapper).toBeDefined();
+ });
+});
diff --git a/console-src/modules/contents/posts/components/entity-fields/ContributorsField.vue b/console-src/modules/contents/posts/components/entity-fields/ContributorsField.vue
new file mode 100644
index 0000000..c479415
--- /dev/null
+++ b/console-src/modules/contents/posts/components/entity-fields/ContributorsField.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/entity-fields/PublishStatusField.vue b/console-src/modules/contents/posts/components/entity-fields/PublishStatusField.vue
new file mode 100644
index 0000000..662afc3
--- /dev/null
+++ b/console-src/modules/contents/posts/components/entity-fields/PublishStatusField.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/entity-fields/PublishTimeField.vue b/console-src/modules/contents/posts/components/entity-fields/PublishTimeField.vue
new file mode 100644
index 0000000..74a13ed
--- /dev/null
+++ b/console-src/modules/contents/posts/components/entity-fields/PublishTimeField.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+ {{ formatDatetime(post.post.spec.publishTime) }}
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/entity-fields/TitleField.vue b/console-src/modules/contents/posts/components/entity-fields/TitleField.vue
new file mode 100644
index 0000000..df9bad2
--- /dev/null
+++ b/console-src/modules/contents/posts/components/entity-fields/TitleField.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post.list.fields.categories") }}
+
+ {{ category.spec.displayName }}
+
+
+
+ {{
+ $t("core.post.list.fields.visits", {
+ visits: post.stats.visit,
+ })
+ }}
+
+
+ {{
+ $t("core.post.list.fields.comments", {
+ comments: post.stats.totalComment || 0,
+ })
+ }}
+
+
+ {{ $t("core.post.list.fields.pinned") }}
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/components/entity-fields/VisibleField.vue b/console-src/modules/contents/posts/components/entity-fields/VisibleField.vue
new file mode 100644
index 0000000..06098b3
--- /dev/null
+++ b/console-src/modules/contents/posts/components/entity-fields/VisibleField.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/composables/use-post-update-mutate.ts b/console-src/modules/contents/posts/composables/use-post-update-mutate.ts
new file mode 100644
index 0000000..2716c75
--- /dev/null
+++ b/console-src/modules/contents/posts/composables/use-post-update-mutate.ts
@@ -0,0 +1,40 @@
+import type { Post } from "@halo-dev/api-client";
+import { coreApiClient } from "@halo-dev/api-client";
+import { Toast } from "@halo-dev/components";
+import { useMutation } from "@tanstack/vue-query";
+import { useI18n } from "vue-i18n";
+
+export function usePostUpdateMutate() {
+ const { t } = useI18n();
+
+ return useMutation({
+ mutationKey: ["post-update"],
+ mutationFn: async (post: Post) => {
+ const { data: latestPost } = await coreApiClient.content.post.getPost({
+ name: post.metadata.name,
+ });
+
+ return await coreApiClient.content.post.updatePost(
+ {
+ name: post.metadata.name,
+ post: {
+ ...latestPost,
+ spec: post.spec,
+ metadata: {
+ ...latestPost.metadata,
+ annotations: post.metadata.annotations,
+ },
+ },
+ },
+ {
+ mute: true,
+ }
+ );
+ },
+ retry: 3,
+ onError: (error) => {
+ console.error("Failed to update post", error);
+ Toast.error(t("core.common.toast.server_internal_error"));
+ },
+ });
+}
diff --git a/console-src/modules/contents/posts/module.ts b/console-src/modules/contents/posts/module.ts
new file mode 100644
index 0000000..6c391b3
--- /dev/null
+++ b/console-src/modules/contents/posts/module.ts
@@ -0,0 +1,110 @@
+import BasicLayout from "@console/layouts/BasicLayout.vue";
+import BlankLayout from "@console/layouts/BlankLayout.vue";
+import { IconBookRead } from "@halo-dev/components";
+import { definePlugin } from "@halo-dev/console-shared";
+import { markRaw } from "vue";
+import DeletedPostList from "./DeletedPostList.vue";
+import PostEditor from "./PostEditor.vue";
+import PostList from "./PostList.vue";
+import PostSnapshots from "./PostSnapshots.vue";
+import CategoryList from "./categories/CategoryList.vue";
+import TagList from "./tags/TagList.vue";
+import PostStatsWidget from "./widgets/PostStatsWidget.vue";
+import RecentPublishedWidget from "./widgets/RecentPublishedWidget.vue";
+
+export default definePlugin({
+ components: {
+ PostStatsWidget,
+ RecentPublishedWidget,
+ },
+ routes: [
+ {
+ path: "/posts",
+ name: "PostsRoot",
+ component: BasicLayout,
+ meta: {
+ title: "core.post.title",
+ searchable: true,
+ permissions: ["system:posts:view"],
+ menu: {
+ name: "core.sidebar.menu.items.posts",
+ group: "content",
+ icon: markRaw(IconBookRead),
+ priority: 0,
+ mobile: true,
+ },
+ },
+ children: [
+ {
+ path: "",
+ name: "Posts",
+ component: PostList,
+ },
+ {
+ path: "deleted",
+ name: "DeletedPosts",
+ component: DeletedPostList,
+ meta: {
+ title: "core.deleted_post.title",
+ searchable: true,
+ permissions: ["system:posts:view"],
+ },
+ },
+ {
+ path: "editor",
+ name: "PostEditor",
+ component: PostEditor,
+ meta: {
+ title: "core.post_editor.title",
+ searchable: true,
+ hideFooter: true,
+ permissions: ["system:posts:manage"],
+ },
+ },
+ {
+ path: "snapshots",
+ name: "PostSnapshots",
+ component: PostSnapshots,
+ meta: {
+ title: "core.post_snapshots.title",
+ searchable: false,
+ hideFooter: true,
+ permissions: ["system:posts:manage"],
+ },
+ },
+ {
+ path: "categories",
+ component: BlankLayout,
+ children: [
+ {
+ path: "",
+ name: "Categories",
+ component: CategoryList,
+ meta: {
+ title: "core.post_category.title",
+ searchable: true,
+ permissions: ["system:posts:view"],
+ },
+ },
+ ],
+ },
+ {
+ path: "tags",
+ component: BlankLayout,
+ children: [
+ {
+ path: "",
+ name: "Tags",
+ component: TagList,
+ meta: {
+ title: "core.post_tag.title",
+ searchable: true,
+ permissions: ["system:posts:view"],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+});
diff --git a/console-src/modules/contents/posts/tags/TagList.vue b/console-src/modules/contents/posts/tags/TagList.vue
new file mode 100644
index 0000000..6391286
--- /dev/null
+++ b/console-src/modules/contents/posts/tags/TagList.vue
@@ -0,0 +1,319 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleFetchTags">
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/tags/components/PostTag.vue b/console-src/modules/contents/posts/tags/components/PostTag.vue
new file mode 100644
index 0000000..b74382a
--- /dev/null
+++ b/console-src/modules/contents/posts/tags/components/PostTag.vue
@@ -0,0 +1,57 @@
+
+
+
+ {{ tag.spec.displayName }}
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/tags/components/TagEditingModal.vue b/console-src/modules/contents/posts/tags/components/TagEditingModal.vue
new file mode 100644
index 0000000..a7b6378
--- /dev/null
+++ b/console-src/modules/contents/posts/tags/components/TagEditingModal.vue
@@ -0,0 +1,290 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post_tag.editing_modal.groups.general") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.post_tag.editing_modal.groups.annotations") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.save_and_continue") }}
+
+
+
+ {{ $t("core.common.buttons.cancel_and_shortcut") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/tags/components/TagListItem.vue b/console-src/modules/contents/posts/tags/components/TagListItem.vue
new file mode 100644
index 0000000..7282633
--- /dev/null
+++ b/console-src/modules/contents/posts/tags/components/TagListItem.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tag.status.permalink }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDatetime(tag.metadata.creationTimestamp) }}
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.edit") }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/tags/composables/use-post-tag.ts b/console-src/modules/contents/posts/tags/composables/use-post-tag.ts
new file mode 100644
index 0000000..446926a
--- /dev/null
+++ b/console-src/modules/contents/posts/tags/composables/use-post-tag.ts
@@ -0,0 +1,138 @@
+import type { Tag } from "@halo-dev/api-client";
+import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
+import { Dialog, Toast } from "@halo-dev/components";
+import { useQuery, type QueryObserverResult } from "@tanstack/vue-query";
+import { ref, watch, type Ref } from "vue";
+import { useI18n } from "vue-i18n";
+
+interface usePostTagReturn {
+ tags: Ref;
+ total: Ref;
+ hasPrevious: Ref;
+ hasNext: Ref;
+ isLoading: Ref;
+ isFetching: Ref;
+ handleFetchTags: () => Promise>;
+ handleDelete: (tag: Tag) => void;
+ handleDeleteInBatch: (tagNames: string[]) => Promise;
+}
+
+export function usePostTag(filterOptions?: {
+ sort?: Ref;
+ page?: Ref;
+ size?: Ref;
+ keyword?: Ref;
+}): usePostTagReturn {
+ const { t } = useI18n();
+
+ const { sort, page, size, keyword } = filterOptions || {};
+
+ const total = ref(0);
+ const hasPrevious = ref(false);
+ const hasNext = ref(false);
+
+ const {
+ data: tags,
+ isLoading,
+ isFetching,
+ refetch,
+ } = useQuery({
+ queryKey: ["post-tags", sort, page, size, keyword],
+ queryFn: async () => {
+ const { data } = await consoleApiClient.content.tag.listPostTags({
+ page: page?.value || 0,
+ size: size?.value || 0,
+ sort: [sort?.value as string].filter(Boolean) || [
+ "metadata.creationTimestamp,desc",
+ ],
+ keyword: keyword?.value,
+ });
+
+ total.value = data.total;
+ hasPrevious.value = data.hasPrevious;
+ hasNext.value = data.hasNext;
+
+ return data.items;
+ },
+ refetchInterval(data) {
+ const abnormalTags = data?.filter(
+ (tag) => !!tag.metadata.deletionTimestamp || !tag.status?.permalink
+ );
+ return abnormalTags?.length ? 1000 : false;
+ },
+ });
+
+ const handleDelete = async (tag: Tag) => {
+ Dialog.warning({
+ title: t("core.post_tag.operations.delete.title"),
+ description: t("core.post_tag.operations.delete.description"),
+ confirmType: "danger",
+ confirmText: t("core.common.buttons.confirm"),
+ cancelText: t("core.common.buttons.cancel"),
+ onConfirm: async () => {
+ try {
+ await coreApiClient.content.tag.deleteTag({
+ name: tag.metadata.name,
+ });
+
+ Toast.success(t("core.common.toast.delete_success"));
+ } catch (e) {
+ console.error("Failed to delete tag", e);
+ } finally {
+ await refetch();
+ }
+ },
+ });
+ };
+
+ const handleDeleteInBatch = (tagNames: string[]) => {
+ return new Promise((resolve) => {
+ Dialog.warning({
+ title: t("core.post_tag.operations.delete_in_batch.title"),
+ description: t("core.common.dialog.descriptions.cannot_be_recovered"),
+ confirmType: "danger",
+ confirmText: t("core.common.buttons.confirm"),
+ cancelText: t("core.common.buttons.cancel"),
+ onConfirm: async () => {
+ try {
+ await Promise.all(
+ tagNames.map((tagName) => {
+ coreApiClient.content.tag.deleteTag({
+ name: tagName,
+ });
+ })
+ );
+
+ Toast.success(t("core.common.toast.delete_success"));
+ resolve();
+ } catch (e) {
+ console.error("Failed to delete tags in batch", e);
+ } finally {
+ await refetch();
+ }
+ },
+ });
+ });
+ };
+
+ watch(
+ () => [sort?.value, keyword?.value],
+ () => {
+ if (page?.value) {
+ page.value = 1;
+ }
+ }
+ );
+
+ return {
+ tags,
+ total,
+ hasPrevious,
+ hasNext,
+ isLoading,
+ isFetching,
+ handleFetchTags: refetch,
+ handleDelete,
+ handleDeleteInBatch,
+ };
+}
diff --git a/console-src/modules/contents/posts/widgets/PostStatsWidget.vue b/console-src/modules/contents/posts/widgets/PostStatsWidget.vue
new file mode 100644
index 0000000..ac86c01
--- /dev/null
+++ b/console-src/modules/contents/posts/widgets/PostStatsWidget.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.dashboard.widgets.presets.post_stats.title") }}
+
+
+ {{ stats?.posts || 0 }}
+
+
+
+
+
+
diff --git a/console-src/modules/contents/posts/widgets/RecentPublishedWidget.vue b/console-src/modules/contents/posts/widgets/RecentPublishedWidget.vue
new file mode 100644
index 0000000..50fe26a
--- /dev/null
+++ b/console-src/modules/contents/posts/widgets/RecentPublishedWidget.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+ -
+
+
+
+
+
+
+ {{
+ $t(
+ "core.dashboard.widgets.presets.recent_published.visits",
+ { visits: post.stats.visit || 0 }
+ )
+ }}
+
+
+ {{
+ $t(
+ "core.dashboard.widgets.presets.recent_published.comments",
+ { comments: post.stats.totalComment || 0 }
+ )
+ }}
+
+
+ {{
+ $t(
+ "core.dashboard.widgets.presets.recent_published.publishTime",
+ {
+ publishTime: formatDatetime(
+ post.post.spec.publishTime
+ ),
+ }
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/dashboard/Dashboard.vue b/console-src/modules/dashboard/Dashboard.vue
new file mode 100644
index 0000000..eb201d3
--- /dev/null
+++ b/console-src/modules/dashboard/Dashboard.vue
@@ -0,0 +1,288 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.dashboard.actions.add_widget") }}
+
+
+
+
+
+
+ {{
+ settings
+ ? $t("core.dashboard.actions.done")
+ : $t("core.dashboard.actions.setting")
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/dashboard/module.ts b/console-src/modules/dashboard/module.ts
new file mode 100644
index 0000000..a33977d
--- /dev/null
+++ b/console-src/modules/dashboard/module.ts
@@ -0,0 +1,41 @@
+import BasicLayout from "@console/layouts/BasicLayout.vue";
+import { IconDashboard } from "@halo-dev/components";
+import { definePlugin } from "@halo-dev/console-shared";
+import Dashboard from "./Dashboard.vue";
+
+import { markRaw } from "vue";
+import QuickLinkWidget from "./widgets/QuickLinkWidget.vue";
+import ViewsStatsWidget from "./widgets/ViewsStatsWidget.vue";
+
+export default definePlugin({
+ components: {
+ QuickLinkWidget,
+ ViewsStatsWidget,
+ },
+ routes: [
+ {
+ path: "/",
+ component: BasicLayout,
+ name: "Root",
+ redirect: "/dashboard",
+ children: [
+ {
+ path: "dashboard",
+ name: "Dashboard",
+ component: Dashboard,
+ meta: {
+ title: "core.dashboard.title",
+ searchable: true,
+ menu: {
+ name: "core.sidebar.menu.items.dashboard",
+ group: "dashboard",
+ icon: markRaw(IconDashboard),
+ priority: 0,
+ mobile: true,
+ },
+ },
+ },
+ ],
+ },
+ ],
+});
diff --git a/console-src/modules/dashboard/widgets/QuickLinkWidget.vue b/console-src/modules/dashboard/widgets/QuickLinkWidget.vue
new file mode 100644
index 0000000..f1530c1
--- /dev/null
+++ b/console-src/modules/dashboard/widgets/QuickLinkWidget.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ action.title }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/dashboard/widgets/ViewsStatsWidget.vue b/console-src/modules/dashboard/widgets/ViewsStatsWidget.vue
new file mode 100644
index 0000000..7fc3da2
--- /dev/null
+++ b/console-src/modules/dashboard/widgets/ViewsStatsWidget.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.dashboard.widgets.presets.views_stats.title") }}
+
+
+ {{ stats?.visits || 0 }}
+
+
+
+
+
+
diff --git a/console-src/modules/index.ts b/console-src/modules/index.ts
new file mode 100644
index 0000000..9e8177a
--- /dev/null
+++ b/console-src/modules/index.ts
@@ -0,0 +1,10 @@
+import type { PluginModule } from "@halo-dev/console-shared";
+
+const modules = Object.values(
+ import.meta.glob("./**/module.ts", {
+ eager: true,
+ import: "default",
+ })
+) as PluginModule[];
+
+export default modules;
diff --git a/console-src/modules/interface/menus/Menus.vue b/console-src/modules/interface/menus/Menus.vue
new file mode 100644
index 0000000..40e7b2a
--- /dev/null
+++ b/console-src/modules/interface/menus/Menus.vue
@@ -0,0 +1,290 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedMenu?.spec.displayName }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/console-src/modules/interface/menus/components/MenuEditingModal.vue b/console-src/modules/interface/menus/components/MenuEditingModal.vue
new file mode 100644
index 0000000..5f487f1
--- /dev/null
+++ b/console-src/modules/interface/menus/components/MenuEditingModal.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.cancel_and_shortcut") }}
+
+
+
+
+
diff --git a/console-src/modules/interface/menus/components/MenuItemEditingModal.vue b/console-src/modules/interface/menus/components/MenuItemEditingModal.vue
new file mode 100644
index 0000000..b9d74e8
--- /dev/null
+++ b/console-src/modules/interface/menus/components/MenuItemEditingModal.vue
@@ -0,0 +1,392 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.menu.menu_item_editing_modal.groups.annotations") }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.cancel_and_shortcut") }}
+
+
+
+
+
diff --git a/console-src/modules/interface/menus/components/MenuItemListItem.vue b/console-src/modules/interface/menus/components/MenuItemListItem.vue
new file mode 100644
index 0000000..9d0543a
--- /dev/null
+++ b/console-src/modules/interface/menus/components/MenuItemListItem.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getMenuItemRefDisplayName(menuItem) }}
+
+
+
+
+ {{ menuItem.status.href }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.edit") }}
+
+
+ {{ $t("core.menu.operations.add_sub_menu_item.button") }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
+
+
diff --git a/console-src/modules/interface/menus/components/MenuList.vue b/console-src/modules/interface/menus/components/MenuList.vue
new file mode 100644
index 0000000..cd4fb1e
--- /dev/null
+++ b/console-src/modules/interface/menus/components/MenuList.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+ {{ $t("core.menu.list.fields.primary") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.menu.operations.set_primary.button") }}
+
+
+ {{ $t("core.common.buttons.edit") }}
+
+
+ {{ $t("core.common.buttons.delete") }}
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
diff --git a/console-src/modules/interface/menus/module.ts b/console-src/modules/interface/menus/module.ts
new file mode 100644
index 0000000..ddfbddd
--- /dev/null
+++ b/console-src/modules/interface/menus/module.ts
@@ -0,0 +1,34 @@
+import BasicLayout from "@console/layouts/BasicLayout.vue";
+import { IconListSettings } from "@halo-dev/components";
+import { definePlugin } from "@halo-dev/console-shared";
+import { markRaw } from "vue";
+import Menus from "./Menus.vue";
+
+export default definePlugin({
+ components: {},
+ routes: [
+ {
+ path: "/menus",
+ name: "MenusRoot",
+ component: BasicLayout,
+ meta: {
+ title: "core.menu.title",
+ searchable: true,
+ permissions: ["system:menus:view"],
+ menu: {
+ name: "core.sidebar.menu.items.menus",
+ group: "interface",
+ icon: markRaw(IconListSettings),
+ priority: 1,
+ },
+ },
+ children: [
+ {
+ path: "",
+ name: "Menus",
+ component: Menus,
+ },
+ ],
+ },
+ ],
+});
diff --git a/console-src/modules/interface/menus/utils/__tests__/__snapshots__/index.spec.ts.snap b/console-src/modules/interface/menus/utils/__tests__/__snapshots__/index.spec.ts.snap
new file mode 100644
index 0000000..9a55b39
--- /dev/null
+++ b/console-src/modules/interface/menus/utils/__tests__/__snapshots__/index.spec.ts.snap
@@ -0,0 +1,342 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`buildMenuItemsTree > should match snapshot 1`] = `
+[
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "411a3639-bf0d-4266-9cfb-14184259dab5",
+ "version": 1,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "首页",
+ "href": "https://ryanc.cc/",
+ "priority": 0,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:19:37.252228Z",
+ "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
+ "version": 12,
+ },
+ "spec": {
+ "children": [
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-07-28T06:50:32.777556Z",
+ "name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "version": 4,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "Halo",
+ "href": "https://ryanc.cc/categories/halo",
+ "priority": 0,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ "version": 1,
+ },
+ "spec": {
+ "children": [
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
+ "version": 1,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "Spring Boot",
+ "href": "https://ryanc.cc/categories/spring-boot",
+ "priority": 0,
+ },
+ },
+ ],
+ "displayName": "Java",
+ "href": "https://ryanc.cc/categories/java",
+ "priority": 1,
+ },
+ },
+ ],
+ "displayName": "文章分类",
+ "href": "https://ryanc.cc/categories",
+ "priority": 1,
+ },
+ },
+]
+`;
+
+exports[`convertMenuTreeItemToMenuItem > should match snapshot 1`] = `
+{
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:19:37.252228Z",
+ "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
+ "version": 12,
+ },
+ "spec": {
+ "children": [
+ "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ ],
+ "displayName": "文章分类",
+ "href": "https://ryanc.cc/categories",
+ "priority": 1,
+ },
+}
+`;
+
+exports[`convertMenuTreeItemToMenuItem > should match snapshot 2`] = `
+{
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ "version": 1,
+ },
+ "spec": {
+ "children": [
+ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
+ ],
+ "displayName": "Java",
+ "href": "https://ryanc.cc/categories/java",
+ "priority": 1,
+ },
+}
+`;
+
+exports[`convertTreeToMenuItems > will match snapshot 1`] = `
+[
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "411a3639-bf0d-4266-9cfb-14184259dab5",
+ "version": 1,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "首页",
+ "href": "https://ryanc.cc/",
+ "priority": 0,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:19:37.252228Z",
+ "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
+ "version": 12,
+ },
+ "spec": {
+ "children": [
+ "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ ],
+ "displayName": "文章分类",
+ "href": "https://ryanc.cc/categories",
+ "priority": 1,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-07-28T06:50:32.777556Z",
+ "name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "version": 4,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "Halo",
+ "href": "https://ryanc.cc/categories/halo",
+ "priority": 0,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ "version": 1,
+ },
+ "spec": {
+ "children": [
+ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
+ ],
+ "displayName": "Java",
+ "href": "https://ryanc.cc/categories/java",
+ "priority": 1,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
+ "version": 1,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "Spring Boot",
+ "href": "https://ryanc.cc/categories/spring-boot",
+ "priority": 0,
+ },
+ },
+]
+`;
+
+exports[`resetMenuItemsTreePriority > should match snapshot 1`] = `
+[
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "411a3639-bf0d-4266-9cfb-14184259dab5",
+ "version": 1,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "首页",
+ "href": "https://ryanc.cc/",
+ "priority": 0,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:19:37.252228Z",
+ "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
+ "version": 12,
+ },
+ "spec": {
+ "children": [
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-07-28T06:50:32.777556Z",
+ "name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "version": 4,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "Halo",
+ "href": "https://ryanc.cc/categories/halo",
+ "priority": 0,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ "version": 1,
+ },
+ "spec": {
+ "children": [
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
+ "version": 1,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "Spring Boot",
+ "href": "https://ryanc.cc/categories/spring-boot",
+ "priority": 0,
+ },
+ },
+ ],
+ "displayName": "Java",
+ "href": "https://ryanc.cc/categories/java",
+ "priority": 1,
+ },
+ },
+ ],
+ "displayName": "文章分类",
+ "href": "https://ryanc.cc/categories",
+ "priority": 1,
+ },
+ },
+]
+`;
+
+exports[`sortMenuItemsTree > will match snapshot 1`] = `
+[
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:19:37.252228Z",
+ "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
+ "version": 12,
+ },
+ "spec": {
+ "children": [
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-08-05T04:22:03.377364Z",
+ "name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ "version": 0,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "Java",
+ "href": "https://ryanc.cc/categories/java",
+ "priority": 0,
+ },
+ },
+ {
+ "apiVersion": "v1alpha1",
+ "kind": "MenuItem",
+ "metadata": {
+ "creationTimestamp": "2022-07-28T06:50:32.777556Z",
+ "name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "version": 4,
+ },
+ "spec": {
+ "children": [],
+ "displayName": "Halo",
+ "href": "https://ryanc.cc/categories/halo",
+ "priority": 1,
+ },
+ },
+ ],
+ "displayName": "文章分类",
+ "href": "https://ryanc.cc/categories",
+ "priority": 0,
+ },
+ },
+]
+`;
diff --git a/console-src/modules/interface/menus/utils/__tests__/index.spec.ts b/console-src/modules/interface/menus/utils/__tests__/index.spec.ts
new file mode 100644
index 0000000..833edf8
--- /dev/null
+++ b/console-src/modules/interface/menus/utils/__tests__/index.spec.ts
@@ -0,0 +1,226 @@
+import type { MenuItem } from "@halo-dev/api-client";
+import { describe, expect, it } from "vitest";
+import type { MenuTreeItem } from "../index";
+import {
+ buildMenuItemsTree,
+ convertMenuTreeItemToMenuItem,
+ convertTreeToMenuItems,
+ getChildrenNames,
+ resetMenuItemsTreePriority,
+ sortMenuItemsTree,
+} from "../index";
+
+const rawMenuItems: MenuItem[] = [
+ {
+ spec: {
+ displayName: "文章分类",
+ href: "https://ryanc.cc/categories",
+ children: [
+ "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ ],
+ priority: 1,
+ },
+ apiVersion: "v1alpha1",
+ kind: "MenuItem",
+ metadata: {
+ name: "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
+ version: 12,
+ creationTimestamp: "2022-08-05T04:19:37.252228Z",
+ },
+ },
+ {
+ spec: {
+ displayName: "Halo",
+ href: "https://ryanc.cc/categories/halo",
+ children: [],
+ priority: 0,
+ },
+ apiVersion: "v1alpha1",
+ kind: "MenuItem",
+ metadata: {
+ name: "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ version: 4,
+ creationTimestamp: "2022-07-28T06:50:32.777556Z",
+ },
+ },
+ {
+ spec: {
+ displayName: "Java",
+ href: "https://ryanc.cc/categories/java",
+ children: ["96b636bb-3e4a-44d1-8ea7-f9da9e876f45"],
+ priority: 1,
+ },
+ apiVersion: "v1alpha1",
+ kind: "MenuItem",
+ metadata: {
+ name: "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ version: 1,
+ creationTimestamp: "2022-08-05T04:22:03.377364Z",
+ },
+ },
+ {
+ spec: {
+ displayName: "Spring Boot",
+ href: "https://ryanc.cc/categories/spring-boot",
+ children: [],
+ priority: 0,
+ },
+ apiVersion: "v1alpha1",
+ kind: "MenuItem",
+ metadata: {
+ name: "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
+ version: 1,
+ creationTimestamp: "2022-08-05T04:22:03.377364Z",
+ },
+ },
+ {
+ spec: {
+ displayName: "首页",
+ href: "https://ryanc.cc/",
+ children: [],
+ priority: 0,
+ },
+ apiVersion: "v1alpha1",
+ kind: "MenuItem",
+ metadata: {
+ name: "411a3639-bf0d-4266-9cfb-14184259dab5",
+ version: 1,
+ creationTimestamp: "2022-08-05T04:22:03.377364Z",
+ },
+ },
+];
+
+describe("buildMenuItemsTree", () => {
+ it("should match snapshot", () => {
+ expect(buildMenuItemsTree(rawMenuItems)).toMatchSnapshot();
+ });
+
+ it("should be sorted correctly", () => {
+ const menuItems = buildMenuItemsTree(rawMenuItems);
+ expect(menuItems[0].spec.priority).toBe(0);
+ expect(menuItems[1].spec.priority).toBe(1);
+
+ // children should be sorted
+ expect(menuItems[1].spec.children[0].spec.priority).toBe(0);
+ expect(menuItems[1].spec.children[1].spec.priority).toBe(1);
+ expect(menuItems[1].spec.children[1].spec.children[0].spec.priority).toBe(
+ 0
+ );
+
+ expect(menuItems[0].spec.displayName).toBe("首页");
+ expect(menuItems[1].spec.displayName).toBe("文章分类");
+ expect(menuItems[1].spec.children[0].spec.displayName).toBe("Halo");
+ expect(menuItems[1].spec.children[1].spec.displayName).toBe("Java");
+ expect(
+ menuItems[1].spec.children[1].spec.children[0].spec.displayName
+ ).toBe("Spring Boot");
+ });
+});
+
+describe("convertTreeToMenuItems", () => {
+ it("will match snapshot", function () {
+ const menuTreeItems = buildMenuItemsTree(rawMenuItems);
+ expect(convertTreeToMenuItems(menuTreeItems)).toMatchSnapshot();
+ });
+});
+
+describe("sortMenuItemsTree", () => {
+ it("will match snapshot", () => {
+ const tree: MenuTreeItem[] = [
+ {
+ apiVersion: "v1alpha1",
+ kind: "MenuItem",
+ metadata: {
+ creationTimestamp: "2022-08-05T04:19:37.252228Z",
+ name: "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
+ version: 12,
+ },
+ spec: {
+ children: [
+ {
+ apiVersion: "v1alpha1",
+ kind: "MenuItem",
+ metadata: {
+ creationTimestamp: "2022-07-28T06:50:32.777556Z",
+ name: "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ version: 4,
+ },
+ spec: {
+ children: [],
+ priority: 1,
+ displayName: "Halo",
+ href: "https://ryanc.cc/categories/halo",
+ },
+ },
+ {
+ apiVersion: "v1alpha1",
+ kind: "MenuItem",
+ metadata: {
+ creationTimestamp: "2022-08-05T04:22:03.377364Z",
+ name: "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ version: 0,
+ },
+ spec: {
+ children: [],
+ priority: 0,
+ displayName: "Java",
+ href: "https://ryanc.cc/categories/java",
+ },
+ },
+ ],
+ priority: 0,
+ displayName: "文章分类",
+ href: "https://ryanc.cc/categories",
+ },
+ },
+ ];
+
+ expect(sortMenuItemsTree(tree)).toMatchSnapshot();
+ });
+});
+
+describe("resetMenuItemsTreePriority", () => {
+ it("should match snapshot", function () {
+ expect(
+ resetMenuItemsTreePriority(buildMenuItemsTree(rawMenuItems))
+ ).toMatchSnapshot();
+ });
+});
+
+describe("getChildrenNames", () => {
+ it("should return correct names", () => {
+ const menuTreeItems = buildMenuItemsTree(rawMenuItems);
+ expect(getChildrenNames(menuTreeItems[0])).toEqual([]);
+ expect(getChildrenNames(menuTreeItems[1])).toEqual([
+ "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
+ ]);
+
+ expect(getChildrenNames(menuTreeItems[1].spec.children[0])).toEqual([]);
+ });
+});
+
+describe("convertMenuTreeItemToMenuItem", () => {
+ it("should match snapshot", () => {
+ const menuTreeItems = buildMenuItemsTree(rawMenuItems);
+ expect(convertMenuTreeItemToMenuItem(menuTreeItems[1])).toMatchSnapshot();
+ expect(
+ convertMenuTreeItemToMenuItem(menuTreeItems[1].spec.children[1])
+ ).toMatchSnapshot();
+ });
+
+ it("should return correct MenuItem", () => {
+ const menuTreeItems = buildMenuItemsTree(rawMenuItems);
+ expect(
+ convertMenuTreeItemToMenuItem(menuTreeItems[1]).spec.displayName
+ ).toBe("文章分类");
+ expect(
+ convertMenuTreeItemToMenuItem(menuTreeItems[1]).spec.children
+ ).toStrictEqual([
+ "caeef383-3828-4039-9114-6f9ad3b4a37e",
+ "ded1943d-9fdb-4563-83ee-2f04364872e0",
+ ]);
+ });
+});
diff --git a/console-src/modules/interface/menus/utils/index.ts b/console-src/modules/interface/menus/utils/index.ts
new file mode 100644
index 0000000..01b963d
--- /dev/null
+++ b/console-src/modules/interface/menus/utils/index.ts
@@ -0,0 +1,265 @@
+import type { MenuItem, MenuItemSpec } from "@halo-dev/api-client";
+import { cloneDeep } from "lodash-es";
+
+export interface MenuTreeItemSpec extends Omit {
+ children: MenuTreeItem[];
+}
+
+export interface MenuTreeItem extends Omit