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 logo + +

+ +> Halo 2.0 的管理端项目(原 halo-admin) + +

+GitHub release +GitHub +GitHub last commit +GitHub Workflow Status +Gitpod ready-to-code +

+ +------------------------------ + +当前仓库已经将 `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 +``` + +## 状态 + +![Repobeats analytics](https://repobeats.axiom.co/api/embed/2db66f0e740d300f1bc6417d4465594755a5545d.svg "Repobeats analytics image") 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 @@ + + 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 @@ + + 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 @@ + 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 @@ + + 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 @@ + + 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 @@ + + + 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 @@ + 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 @@ + + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 `${displayName}`; + } 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 `![${displayName}](${permalink})`; + } + 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 @@ + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + + 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 @@ + + 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 @@ + + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + + 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 @@ + + 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 @@ + + 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 @@ + + + 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 @@ + + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 { + spec: MenuTreeItemSpec; +} + +/** + * Convert a flat array of menu items into flattens a menu tree. + * + * Example: + * + * ```json + * [ + * { + * "spec": { + * "displayName": "文章分类", + * "href": "https://ryanc.cc/categories", + * "children": [ + * "caeef383-3828-4039-9114-6f9ad3b4a37e", + * "ded1943d-9fdb-4563-83ee-2f04364872e0" + * ] + * }, + * "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": [] + * }, + * "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": [] + * }, + * "apiVersion": "v1alpha1", + * "kind": "MenuItem", + * "metadata": { + * "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", + * "version": 1, + * "creationTimestamp": "2022-08-05T04:22:03.377364Z" + * } + * } + * ] + * ``` + * + * will be transformed to: + * + * ```json + * [ + * { + * "spec": { + * "displayName": "文章分类", + * "href": "https://ryanc.cc/categories", + * "children": [ + * { + * "spec": { + * "displayName": "Halo", + * "href": "https://ryanc.cc/categories/halo", + * "children": [] + * }, + * "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": [] + * }, + * "apiVersion": "v1alpha1", + * "kind": "MenuItem", + * "metadata": { + * "name": "ded1943d-9fdb-4563-83ee-2f04364872e0", + * "version": 1, + * "creationTimestamp": "2022-08-05T04:22:03.377364Z" + * } + * } + * ] + * }, + * "apiVersion": "v1alpha1", + * "kind": "MenuItem", + * "metadata": { + * "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564", + * "version": 12, + * "creationTimestamp": "2022-08-05T04:19:37.252228Z" + * } + * } + * ] + * ``` + * + * @param menuItems + */ +export function buildMenuItemsTree(menuItems: MenuItem[]): MenuTreeItem[] { + const menuItemsToUpdate = cloneDeep(menuItems); + + const menuItemsMap = {}; + const parentMap = {}; + + menuItemsToUpdate.forEach((menuItem) => { + menuItemsMap[menuItem.metadata.name] = menuItem; + // @ts-ignore + menuItem.spec.children.forEach((child) => { + parentMap[child] = menuItem.metadata.name; + }); + menuItem.spec.children = []; + }); + + menuItemsToUpdate.forEach((menuItem) => { + const parentName = parentMap[menuItem.metadata.name]; + if (parentName && menuItemsMap[parentName]) { + menuItemsMap[parentName].spec.children.push(menuItem); + } + }); + + const menuTreeItems = menuItemsToUpdate.filter( + (node) => parentMap[node.metadata.name] === undefined + ); + + return sortMenuItemsTree(menuTreeItems); +} + +/** + * Sort a menu tree by priority. + * + * @param menuTreeItems + */ +export function sortMenuItemsTree( + menuTreeItems: MenuTreeItem[] | MenuItem[] +): MenuTreeItem[] { + return menuTreeItems + .sort((a, b) => { + if (a.spec.priority < b.spec.priority) { + return -1; + } + if (a.spec.priority > b.spec.priority) { + return 1; + } + return 0; + }) + .map((menuTreeItem) => { + if (menuTreeItem.spec.children.length) { + return { + ...menuTreeItem, + spec: { + ...menuTreeItem.spec, + children: sortMenuItemsTree(menuTreeItem.spec.children), + }, + }; + } + return menuTreeItem; + }); +} + +/** + * Reset the menu tree item's priority. + * + * @param menuItems + */ +export function resetMenuItemsTreePriority( + menuItems: MenuTreeItem[] +): MenuTreeItem[] { + for (let i = 0; i < menuItems.length; i++) { + menuItems[i].spec.priority = i; + if (menuItems[i].spec.children) { + resetMenuItemsTreePriority(menuItems[i].spec.children); + } + } + return menuItems; +} + +/** + * Convert a menu tree items into a flat array of menu. + * + * @param menuTreeItems + */ +export function convertTreeToMenuItems(menuTreeItems: MenuTreeItem[]) { + const menuItems: MenuItem[] = []; + const menuItemsMap = new Map(); + const convertMenuItem = (node: MenuTreeItem | undefined) => { + if (!node) { + return; + } + const children = node.spec.children || []; + menuItemsMap.set(node.metadata.name, { + ...node, + spec: { + ...node.spec, + children: children.map((child) => child.metadata.name), + }, + }); + children.forEach((child) => { + convertMenuItem(child); + }); + }; + menuTreeItems.forEach((node) => { + convertMenuItem(node); + }); + menuItemsMap.forEach((node) => { + menuItems.push(node); + }); + return menuItems; +} + +export function getChildrenNames(menuTreeItem: MenuTreeItem): string[] { + const childrenNames: string[] = []; + + function getChildrenNamesRecursive(menuTreeItem: MenuTreeItem) { + if (menuTreeItem.spec.children) { + menuTreeItem.spec.children.forEach((child) => { + childrenNames.push(child.metadata.name); + getChildrenNamesRecursive(child); + }); + } + } + + getChildrenNamesRecursive(menuTreeItem); + + return childrenNames; +} + +/** + * Convert {@link MenuTreeItem} to {@link MenuItem} with flat children name array. + * + * @param menuTreeItem + */ +export function convertMenuTreeItemToMenuItem( + menuTreeItem: MenuTreeItem +): MenuItem { + const childNames = menuTreeItem.spec.children.map( + (child) => child.metadata.name + ); + return { + ...menuTreeItem, + spec: { + ...menuTreeItem.spec, + children: childNames, + }, + }; +} diff --git a/console-src/modules/interface/themes/ThemeDetail.vue b/console-src/modules/interface/themes/ThemeDetail.vue new file mode 100644 index 0000000..413b472 --- /dev/null +++ b/console-src/modules/interface/themes/ThemeDetail.vue @@ -0,0 +1,253 @@ + + + diff --git a/console-src/modules/interface/themes/ThemeSetting.vue b/console-src/modules/interface/themes/ThemeSetting.vue new file mode 100644 index 0000000..7f207c8 --- /dev/null +++ b/console-src/modules/interface/themes/ThemeSetting.vue @@ -0,0 +1,105 @@ + + diff --git a/console-src/modules/interface/themes/components/ThemeListItem.vue b/console-src/modules/interface/themes/components/ThemeListItem.vue new file mode 100644 index 0000000..fd56640 --- /dev/null +++ b/console-src/modules/interface/themes/components/ThemeListItem.vue @@ -0,0 +1,291 @@ + + + diff --git a/console-src/modules/interface/themes/components/ThemeListModal.vue b/console-src/modules/interface/themes/components/ThemeListModal.vue new file mode 100644 index 0000000..774d466 --- /dev/null +++ b/console-src/modules/interface/themes/components/ThemeListModal.vue @@ -0,0 +1,161 @@ + + diff --git a/console-src/modules/interface/themes/components/list-tabs/InstalledThemes.vue b/console-src/modules/interface/themes/components/list-tabs/InstalledThemes.vue new file mode 100644 index 0000000..401608f --- /dev/null +++ b/console-src/modules/interface/themes/components/list-tabs/InstalledThemes.vue @@ -0,0 +1,114 @@ + + + diff --git a/console-src/modules/interface/themes/components/list-tabs/LocalUpload.vue b/console-src/modules/interface/themes/components/list-tabs/LocalUpload.vue new file mode 100644 index 0000000..51beeb9 --- /dev/null +++ b/console-src/modules/interface/themes/components/list-tabs/LocalUpload.vue @@ -0,0 +1,86 @@ + + + diff --git a/console-src/modules/interface/themes/components/list-tabs/NotInstalledThemes.vue b/console-src/modules/interface/themes/components/list-tabs/NotInstalledThemes.vue new file mode 100644 index 0000000..3a5e451 --- /dev/null +++ b/console-src/modules/interface/themes/components/list-tabs/NotInstalledThemes.vue @@ -0,0 +1,48 @@ + + + diff --git a/console-src/modules/interface/themes/components/list-tabs/RemoteDownload.vue b/console-src/modules/interface/themes/components/list-tabs/RemoteDownload.vue new file mode 100644 index 0000000..1267e42 --- /dev/null +++ b/console-src/modules/interface/themes/components/list-tabs/RemoteDownload.vue @@ -0,0 +1,121 @@ + + + diff --git a/console-src/modules/interface/themes/components/operation/MoreOperationItem.vue b/console-src/modules/interface/themes/components/operation/MoreOperationItem.vue new file mode 100644 index 0000000..f08a7c1 --- /dev/null +++ b/console-src/modules/interface/themes/components/operation/MoreOperationItem.vue @@ -0,0 +1,7 @@ + + + diff --git a/console-src/modules/interface/themes/components/operation/UninstallOperationItem.vue b/console-src/modules/interface/themes/components/operation/UninstallOperationItem.vue new file mode 100644 index 0000000..20f86cb --- /dev/null +++ b/console-src/modules/interface/themes/components/operation/UninstallOperationItem.vue @@ -0,0 +1,86 @@ + + + diff --git a/console-src/modules/interface/themes/components/preview/ThemePreviewListItem.vue b/console-src/modules/interface/themes/components/preview/ThemePreviewListItem.vue new file mode 100644 index 0000000..45cbffa --- /dev/null +++ b/console-src/modules/interface/themes/components/preview/ThemePreviewListItem.vue @@ -0,0 +1,91 @@ + + + diff --git a/console-src/modules/interface/themes/components/preview/ThemePreviewModal.vue b/console-src/modules/interface/themes/components/preview/ThemePreviewModal.vue new file mode 100644 index 0000000..d7864dd --- /dev/null +++ b/console-src/modules/interface/themes/components/preview/ThemePreviewModal.vue @@ -0,0 +1,404 @@ + + diff --git a/console-src/modules/interface/themes/composables/use-theme.ts b/console-src/modules/interface/themes/composables/use-theme.ts new file mode 100644 index 0000000..5215f2c --- /dev/null +++ b/console-src/modules/interface/themes/composables/use-theme.ts @@ -0,0 +1,304 @@ +import { useThemeStore } from "@console/stores/theme"; +import type { Theme } from "@halo-dev/api-client"; +import { consoleApiClient } from "@halo-dev/api-client"; +import { Dialog, Toast } from "@halo-dev/components"; +import { useFileDialog } from "@vueuse/core"; +import { storeToRefs } from "pinia"; +import type { ComputedRef, Ref } from "vue"; +import { computed, ref } from "vue"; +import { useI18n } from "vue-i18n"; + +interface useThemeLifeCycleReturn { + loading: Ref; + isActivated: ComputedRef; + getFailedMessage: () => string | undefined; + handleActiveTheme: (reload?: boolean) => void; + handleResetSettingConfig: () => void; +} + +export function useThemeLifeCycle( + theme: Ref +): useThemeLifeCycleReturn { + const { t } = useI18n(); + + const loading = ref(false); + + const themeStore = useThemeStore(); + + const { activatedTheme } = storeToRefs(themeStore); + + const isActivated = computed(() => { + return activatedTheme?.value?.metadata.name === theme.value?.metadata.name; + }); + + const getFailedMessage = (): string | undefined => { + if (!(theme.value?.status?.phase === "FAILED")) { + return; + } + + const condition = theme.value.status.conditions?.[0]; + + if (condition) { + return [condition.type, condition.message].join(":"); + } + }; + + const handleActiveTheme = async (reload?: boolean) => { + Dialog.info({ + title: t("core.theme.operations.active.title"), + description: theme.value?.spec.displayName, + confirmText: t("core.common.buttons.confirm"), + cancelText: t("core.common.buttons.cancel"), + onConfirm: async () => { + try { + if (!theme.value) return; + + await consoleApiClient.theme.theme.activateTheme({ + name: theme.value?.metadata.name, + }); + + Toast.success(t("core.theme.operations.active.toast_success")); + + if (reload) { + window.location.reload(); + } + } catch (e) { + console.error("Failed to active theme", e); + } finally { + themeStore.fetchActivatedTheme(); + } + }, + }); + }; + + const handleResetSettingConfig = async () => { + Dialog.warning({ + title: t("core.theme.operations.reset.title"), + description: t("core.theme.operations.reset.description"), + confirmType: "danger", + confirmText: t("core.common.buttons.confirm"), + cancelText: t("core.common.buttons.cancel"), + onConfirm: async () => { + try { + if (!theme?.value) { + return; + } + + await consoleApiClient.theme.theme.resetThemeConfig({ + name: theme.value.metadata.name as string, + }); + + Toast.success(t("core.theme.operations.reset.toast_success")); + } catch (e) { + console.error("Failed to reset theme setting config", e); + } + }, + }); + }; + + return { + loading, + isActivated, + getFailedMessage, + handleActiveTheme, + handleResetSettingConfig, + }; +} + +export function useThemeCustomTemplates(type: "post" | "page" | "category") { + const themeStore = useThemeStore(); + const { t } = useI18n(); + + const templates = computed(() => { + const defaultTemplate = [ + { + label: t("core.theme.custom_templates.default"), + value: "", + }, + ]; + + if (!themeStore.activatedTheme) { + return defaultTemplate; + } + const { customTemplates } = themeStore.activatedTheme.spec; + if (!customTemplates?.[type]) { + return defaultTemplate; + } + return [ + ...defaultTemplate, + ...(customTemplates[type]?.map((template) => { + return { + value: template.file, + label: template.name || template.file, + }; + }) || []), + ]; + }); + + return { + templates, + }; +} + +interface ExportData { + themeName: string; + version: string; + settingName: string; + configMapName: string; + configs: { [key: string]: string }; +} + +export function useThemeConfigFile(theme: Ref) { + const { t } = useI18n(); + + const handleExportThemeConfiguration = async () => { + if (!theme.value) { + console.error("No selected or activated theme"); + return; + } + + const { data } = await consoleApiClient.theme.theme.fetchThemeConfig({ + name: theme?.value?.metadata.name as string, + }); + if (!data) { + console.error("Failed to fetch theme config"); + return; + } + + const themeName = theme.value.metadata.name; + const exportData = { + themeName: themeName, + version: theme.value.spec.version, + settingName: theme.value.spec.settingName, + configMapName: theme.value.spec.configMapName, + configs: data.data, + } as ExportData; + const exportStr = JSON.stringify(exportData, null, 2); + const blob = new Blob([exportStr], { type: "application/json" }); + const temporaryExportUrl = URL.createObjectURL(blob); + const temporaryLinkTag = document.createElement("a"); + + temporaryLinkTag.href = temporaryExportUrl; + temporaryLinkTag.download = `export-${themeName}-config-${Date.now().toString()}.json`; + + document.body.appendChild(temporaryLinkTag); + temporaryLinkTag.click(); + + document.body.removeChild(temporaryLinkTag); + URL.revokeObjectURL(temporaryExportUrl); + }; + + const { + open: openSelectImportFileDialog, + onChange: handleImportThemeConfiguration, + } = useFileDialog({ + accept: "application/json", + multiple: false, + directory: false, + reset: true, + }); + + handleImportThemeConfiguration(async (files) => { + if (files === null || files.length === 0) { + return; + } + const configText = await files[0].text(); + const configJson = JSON.parse(configText || "{}"); + if (!configJson.configs) { + return; + } + if (!configJson.themeName || !configJson.version) { + Toast.error( + t("core.theme.operations.import_configuration.invalid_format") + ); + return; + } + if (!theme.value) { + console.error("No selected or activated theme"); + return; + } + if (configJson.themeName !== theme.value.metadata.name) { + Toast.error( + t("core.theme.operations.import_configuration.mismatched_theme") + ); + return; + } + + if (configJson.version !== theme.value.spec.version) { + Dialog.warning({ + title: t( + "core.theme.operations.import_configuration.version_mismatch.title" + ), + description: t( + "core.theme.operations.import_configuration.version_mismatch.description" + ), + confirmText: t("core.common.buttons.confirm"), + cancelText: t("core.common.buttons.cancel"), + onConfirm: () => { + handleSaveConfigMap(configJson.configs); + }, + onCancel() { + return; + }, + }); + return; + } + handleSaveConfigMap(configJson.configs); + }); + + const handleSaveConfigMap = async (importData: Record) => { + if (!theme.value) { + return; + } + const { data } = await consoleApiClient.theme.theme.fetchThemeConfig({ + name: theme.value.metadata.name as string, + }); + if (!data || !data.data) { + return; + } + const combinedConfigData = combinedConfigMap(data.data, importData); + await consoleApiClient.theme.theme.updateThemeConfig({ + name: theme.value.metadata.name, + configMap: { + ...data, + data: combinedConfigData, + }, + }); + Toast.success(t("core.common.toast.save_success")); + }; + + /** + * combined benchmark configuration and import configuration + * + * benchmark: { a: "{\"a\": 1}", b: "{\"b\": 2}" } + * expand: { a: "{\"c\": 3}", b: "{\"d\": 4}" } + * => { a: "{\"a\": 1, \"c\": 3}", b: "{\"b\": 2, \"d\": 4}" } + * + * benchmark: { a: "{\"a\": 1}", b: "{\"b\": 2}", d: "{\"d\": 4}" + * expand: { a: "{\"a\": 2}", b: "{\"b\": 3, \"d\": 4}", c: "{\"c\": 5}" } + * => { a: "{\"a\": 2}", b: "{\"b\": 3, \"d\": 4}", d: "{\"d\": 4}" } + * + */ + const combinedConfigMap = ( + benchmarkConfigMap: { [key: string]: string }, + importConfigMap: { [key: string]: string } + ): { [key: string]: string } => { + const result = benchmarkConfigMap; + + for (const key in result) { + const benchmarkValueJson = JSON.parse(benchmarkConfigMap[key] || "{}"); + const expandValueJson = JSON.parse(importConfigMap[key] || "{}"); + const combinedValue = { + ...benchmarkValueJson, + ...expandValueJson, + }; + result[key] = JSON.stringify(combinedValue); + } + return result; + }; + + return { + handleExportThemeConfiguration, + openSelectImportFileDialog, + }; +} diff --git a/console-src/modules/interface/themes/constants/index.ts b/console-src/modules/interface/themes/constants/index.ts new file mode 100644 index 0000000..b042309 --- /dev/null +++ b/console-src/modules/interface/themes/constants/index.ts @@ -0,0 +1,2 @@ +export const THEME_ALREADY_EXISTS_TYPE = + "https://halo.run/probs/theme-alreay-exists"; diff --git a/console-src/modules/interface/themes/layouts/ThemeLayout.vue b/console-src/modules/interface/themes/layouts/ThemeLayout.vue new file mode 100644 index 0000000..98cb1fa --- /dev/null +++ b/console-src/modules/interface/themes/layouts/ThemeLayout.vue @@ -0,0 +1,289 @@ + + diff --git a/console-src/modules/interface/themes/module.ts b/console-src/modules/interface/themes/module.ts new file mode 100644 index 0000000..9c4bfba --- /dev/null +++ b/console-src/modules/interface/themes/module.ts @@ -0,0 +1,44 @@ +import { IconPalette } from "@halo-dev/components"; +import { definePlugin } from "@halo-dev/console-shared"; +import { markRaw } from "vue"; +import ThemeDetail from "./ThemeDetail.vue"; +import ThemeSetting from "./ThemeSetting.vue"; +import ThemeLayout from "./layouts/ThemeLayout.vue"; + +export default definePlugin({ + components: {}, + routes: [ + { + path: "/theme", + name: "ThemeRoot", + component: ThemeLayout, + meta: { + title: "core.theme.title", + searchable: true, + permissions: ["system:themes:view"], + menu: { + name: "core.sidebar.menu.items.themes", + group: "interface", + icon: markRaw(IconPalette), + priority: 0, + }, + }, + children: [ + { + path: "", + name: "ThemeDetail", + component: ThemeDetail, + }, + { + path: "settings/:group", + name: "ThemeSetting", + component: ThemeSetting, + meta: { + title: "core.theme.settings.title", + permissions: ["system:themes:view"], + }, + }, + ], + }, + ], +}); diff --git a/console-src/modules/interface/themes/types/index.ts b/console-src/modules/interface/themes/types/index.ts new file mode 100644 index 0000000..97f6560 --- /dev/null +++ b/console-src/modules/interface/themes/types/index.ts @@ -0,0 +1,10 @@ +export interface ThemeInstallationErrorResponse { + detail: string; + instance: string; + themeName: string; + requestId: string; + status: number; + timestamp: string; + title: string; + type: string; +} diff --git a/console-src/modules/system/auth-providers/AuthProviderDetail.vue b/console-src/modules/system/auth-providers/AuthProviderDetail.vue new file mode 100644 index 0000000..eab704f --- /dev/null +++ b/console-src/modules/system/auth-providers/AuthProviderDetail.vue @@ -0,0 +1,250 @@ + + + diff --git a/console-src/modules/system/auth-providers/AuthProviders.vue b/console-src/modules/system/auth-providers/AuthProviders.vue new file mode 100644 index 0000000..416ba85 --- /dev/null +++ b/console-src/modules/system/auth-providers/AuthProviders.vue @@ -0,0 +1,132 @@ + + + diff --git a/console-src/modules/system/auth-providers/components/AuthProviderListItem.vue b/console-src/modules/system/auth-providers/components/AuthProviderListItem.vue new file mode 100644 index 0000000..794d97e --- /dev/null +++ b/console-src/modules/system/auth-providers/components/AuthProviderListItem.vue @@ -0,0 +1,120 @@ + + + diff --git a/console-src/modules/system/auth-providers/module.ts b/console-src/modules/system/auth-providers/module.ts new file mode 100644 index 0000000..a2cb8d9 --- /dev/null +++ b/console-src/modules/system/auth-providers/module.ts @@ -0,0 +1,32 @@ +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import { definePlugin } from "@halo-dev/console-shared"; +import AuthProviderDetail from "./AuthProviderDetail.vue"; +import AuthProviders from "./AuthProviders.vue"; + +export default definePlugin({ + routes: [ + { + path: "/users/auth-providers", + component: BasicLayout, + children: [ + { + path: "", + name: "AuthProviders", + component: AuthProviders, + meta: { + title: "core.identity_authentication.title", + searchable: true, + }, + }, + { + path: ":name", + name: "AuthProviderDetail", + component: AuthProviderDetail, + meta: { + title: "core.identity_authentication.detail.title", + }, + }, + ], + }, + ], +}); diff --git a/console-src/modules/system/backup/Backups.vue b/console-src/modules/system/backup/Backups.vue new file mode 100644 index 0000000..b04fda7 --- /dev/null +++ b/console-src/modules/system/backup/Backups.vue @@ -0,0 +1,95 @@ + + + diff --git a/console-src/modules/system/backup/components/BackupListItem.vue b/console-src/modules/system/backup/components/BackupListItem.vue new file mode 100644 index 0000000..41fc87f --- /dev/null +++ b/console-src/modules/system/backup/components/BackupListItem.vue @@ -0,0 +1,199 @@ + + + diff --git a/console-src/modules/system/backup/composables/use-backup.ts b/console-src/modules/system/backup/composables/use-backup.ts new file mode 100644 index 0000000..8b0d172 --- /dev/null +++ b/console-src/modules/system/backup/composables/use-backup.ts @@ -0,0 +1,74 @@ +import { BackupStatusPhaseEnum, coreApiClient } from "@halo-dev/api-client"; +import { Dialog, Toast } from "@halo-dev/components"; +import { useQuery, useQueryClient } from "@tanstack/vue-query"; +import dayjs from "dayjs"; +import { useI18n } from "vue-i18n"; + +export function useBackupFetch() { + return useQuery({ + queryKey: ["backups"], + queryFn: async () => { + const { data } = await coreApiClient.migration.backup.listBackup({ + sort: ["metadata.creationTimestamp,desc"], + }); + return data; + }, + refetchInterval(data) { + const deletingBackups = data?.items.filter((backup) => { + return !!backup.metadata.deletionTimestamp; + }); + + if (deletingBackups?.length) { + return 1000; + } + + const pendingBackups = data?.items.filter((backup) => { + return ( + backup.status?.phase === BackupStatusPhaseEnum.Pending || + backup.status?.phase === BackupStatusPhaseEnum.Running + ); + }); + + if (pendingBackups?.length) { + return 3000; + } + + return false; + }, + }); +} + +export function useBackup() { + const { t } = useI18n(); + const queryClient = useQueryClient(); + + const handleCreate = async () => { + Dialog.info({ + title: t("core.backup.operations.create.title"), + description: t("core.backup.operations.create.description"), + confirmText: t("core.common.buttons.confirm"), + cancelText: t("core.common.buttons.cancel"), + async onConfirm() { + await coreApiClient.migration.backup.createBackup({ + backup: { + apiVersion: "migration.halo.run/v1alpha1", + kind: "Backup", + metadata: { + generateName: "backup-", + name: "", + }, + spec: { + expiresAt: dayjs().add(7, "day").toISOString(), + }, + }, + }); + + queryClient.invalidateQueries({ queryKey: ["backups"] }); + + Toast.success(t("core.backup.operations.create.toast_success")); + }, + }); + }; + + return { handleCreate }; +} diff --git a/console-src/modules/system/backup/module.ts b/console-src/modules/system/backup/module.ts new file mode 100644 index 0000000..3d68e11 --- /dev/null +++ b/console-src/modules/system/backup/module.ts @@ -0,0 +1,34 @@ +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import { IconServerLine } from "@halo-dev/components"; +import { definePlugin } from "@halo-dev/console-shared"; +import { markRaw } from "vue"; +import Backups from "./Backups.vue"; + +export default definePlugin({ + components: {}, + routes: [ + { + path: "/backup", + name: "BackupRoot", + component: BasicLayout, + meta: { + title: "core.backup.title", + searchable: true, + permissions: ["system:migrations:manage"], + menu: { + name: "core.sidebar.menu.items.backup", + group: "system", + icon: markRaw(IconServerLine), + priority: 4, + }, + }, + children: [ + { + path: "", + name: "Backup", + component: Backups, + }, + ], + }, + ], +}); diff --git a/console-src/modules/system/backup/tabs/List.vue b/console-src/modules/system/backup/tabs/List.vue new file mode 100644 index 0000000..aa815e2 --- /dev/null +++ b/console-src/modules/system/backup/tabs/List.vue @@ -0,0 +1,30 @@ + + + diff --git a/console-src/modules/system/backup/tabs/Restore.vue b/console-src/modules/system/backup/tabs/Restore.vue new file mode 100644 index 0000000..6a0f940 --- /dev/null +++ b/console-src/modules/system/backup/tabs/Restore.vue @@ -0,0 +1,228 @@ + + + diff --git a/console-src/modules/system/overview/Overview.vue b/console-src/modules/system/overview/Overview.vue new file mode 100644 index 0000000..8e1ec2d --- /dev/null +++ b/console-src/modules/system/overview/Overview.vue @@ -0,0 +1,403 @@ + + + diff --git a/console-src/modules/system/overview/module.ts b/console-src/modules/system/overview/module.ts new file mode 100644 index 0000000..df1a12e --- /dev/null +++ b/console-src/modules/system/overview/module.ts @@ -0,0 +1,34 @@ +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import { IconTerminalBoxLine } from "@halo-dev/components"; +import { definePlugin } from "@halo-dev/console-shared"; +import { markRaw } from "vue"; +import Overview from "./Overview.vue"; + +export default definePlugin({ + components: {}, + routes: [ + { + path: "/overview", + name: "OverviewRoot", + component: BasicLayout, + meta: { + title: "core.overview.title", + searchable: true, + permissions: ["system:actuator:manage"], + menu: { + name: "core.sidebar.menu.items.overview", + group: "system", + icon: markRaw(IconTerminalBoxLine), + priority: 3, + }, + }, + children: [ + { + path: "", + name: "Overview", + component: Overview, + }, + ], + }, + ], +}); diff --git a/console-src/modules/system/plugins/PluginDetail.vue b/console-src/modules/system/plugins/PluginDetail.vue new file mode 100644 index 0000000..c12735f --- /dev/null +++ b/console-src/modules/system/plugins/PluginDetail.vue @@ -0,0 +1,52 @@ + + diff --git a/console-src/modules/system/plugins/PluginExtensionPointSettings.vue b/console-src/modules/system/plugins/PluginExtensionPointSettings.vue new file mode 100644 index 0000000..6debe0c --- /dev/null +++ b/console-src/modules/system/plugins/PluginExtensionPointSettings.vue @@ -0,0 +1,138 @@ + + + diff --git a/console-src/modules/system/plugins/PluginList.vue b/console-src/modules/system/plugins/PluginList.vue new file mode 100644 index 0000000..7c92693 --- /dev/null +++ b/console-src/modules/system/plugins/PluginList.vue @@ -0,0 +1,348 @@ + + diff --git a/console-src/modules/system/plugins/components/PluginConditionsModal.vue b/console-src/modules/system/plugins/components/PluginConditionsModal.vue new file mode 100644 index 0000000..2bcaac9 --- /dev/null +++ b/console-src/modules/system/plugins/components/PluginConditionsModal.vue @@ -0,0 +1,93 @@ + + + diff --git a/console-src/modules/system/plugins/components/PluginDetailModal.vue b/console-src/modules/system/plugins/components/PluginDetailModal.vue new file mode 100644 index 0000000..7d2a2c2 --- /dev/null +++ b/console-src/modules/system/plugins/components/PluginDetailModal.vue @@ -0,0 +1,66 @@ + + + diff --git a/console-src/modules/system/plugins/components/PluginInstallationModal.vue b/console-src/modules/system/plugins/components/PluginInstallationModal.vue new file mode 100644 index 0000000..3d5f53d --- /dev/null +++ b/console-src/modules/system/plugins/components/PluginInstallationModal.vue @@ -0,0 +1,146 @@ + + diff --git a/console-src/modules/system/plugins/components/PluginListItem.vue b/console-src/modules/system/plugins/components/PluginListItem.vue new file mode 100644 index 0000000..b0147e1 --- /dev/null +++ b/console-src/modules/system/plugins/components/PluginListItem.vue @@ -0,0 +1,288 @@ + + diff --git a/console-src/modules/system/plugins/components/entity-fields/AuthorField.vue b/console-src/modules/system/plugins/components/entity-fields/AuthorField.vue new file mode 100644 index 0000000..c573fa1 --- /dev/null +++ b/console-src/modules/system/plugins/components/entity-fields/AuthorField.vue @@ -0,0 +1,24 @@ + + + diff --git a/console-src/modules/system/plugins/components/entity-fields/LogoField.vue b/console-src/modules/system/plugins/components/entity-fields/LogoField.vue new file mode 100644 index 0000000..b7cf153 --- /dev/null +++ b/console-src/modules/system/plugins/components/entity-fields/LogoField.vue @@ -0,0 +1,22 @@ + + + diff --git a/console-src/modules/system/plugins/components/entity-fields/ReloadField.vue b/console-src/modules/system/plugins/components/entity-fields/ReloadField.vue new file mode 100644 index 0000000..920287d --- /dev/null +++ b/console-src/modules/system/plugins/components/entity-fields/ReloadField.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/console-src/modules/system/plugins/components/entity-fields/SwitchField.vue b/console-src/modules/system/plugins/components/entity-fields/SwitchField.vue new file mode 100644 index 0000000..3897901 --- /dev/null +++ b/console-src/modules/system/plugins/components/entity-fields/SwitchField.vue @@ -0,0 +1,30 @@ + + + diff --git a/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionListItem.vue b/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionListItem.vue new file mode 100644 index 0000000..a73a093 --- /dev/null +++ b/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionListItem.vue @@ -0,0 +1,82 @@ + + + diff --git a/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionMultiInstanceView.vue b/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionMultiInstanceView.vue new file mode 100644 index 0000000..8334a8f --- /dev/null +++ b/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionMultiInstanceView.vue @@ -0,0 +1,49 @@ + + + diff --git a/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionSingletonView.vue b/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionSingletonView.vue new file mode 100644 index 0000000..02586b7 --- /dev/null +++ b/console-src/modules/system/plugins/components/extension-points/ExtensionDefinitionSingletonView.vue @@ -0,0 +1,153 @@ + + + diff --git a/console-src/modules/system/plugins/components/installation-tabs/LocalUpload.vue b/console-src/modules/system/plugins/components/installation-tabs/LocalUpload.vue new file mode 100644 index 0000000..80063d1 --- /dev/null +++ b/console-src/modules/system/plugins/components/installation-tabs/LocalUpload.vue @@ -0,0 +1,122 @@ + + + diff --git a/console-src/modules/system/plugins/components/installation-tabs/RemoteDownload.vue b/console-src/modules/system/plugins/components/installation-tabs/RemoteDownload.vue new file mode 100644 index 0000000..3d1133a --- /dev/null +++ b/console-src/modules/system/plugins/components/installation-tabs/RemoteDownload.vue @@ -0,0 +1,163 @@ + + + diff --git a/console-src/modules/system/plugins/components/tabs/Detail.vue b/console-src/modules/system/plugins/components/tabs/Detail.vue new file mode 100644 index 0000000..0a3a393 --- /dev/null +++ b/console-src/modules/system/plugins/components/tabs/Detail.vue @@ -0,0 +1,289 @@ + + + diff --git a/console-src/modules/system/plugins/components/tabs/Setting.vue b/console-src/modules/system/plugins/components/tabs/Setting.vue new file mode 100644 index 0000000..460a82f --- /dev/null +++ b/console-src/modules/system/plugins/components/tabs/Setting.vue @@ -0,0 +1,94 @@ + + diff --git a/console-src/modules/system/plugins/composables/use-extension-definition-fetch.ts b/console-src/modules/system/plugins/composables/use-extension-definition-fetch.ts new file mode 100644 index 0000000..06947fd --- /dev/null +++ b/console-src/modules/system/plugins/composables/use-extension-definition-fetch.ts @@ -0,0 +1,22 @@ +import type { ExtensionPointDefinition } from "@halo-dev/api-client"; +import { coreApiClient } from "@halo-dev/api-client"; +import { useQuery } from "@tanstack/vue-query"; +import { computed, type Ref } from "vue"; + +export function useExtensionDefinitionFetch( + extensionPointDefinition: Ref +) { + return useQuery({ + queryKey: ["extension-definitions", extensionPointDefinition], + queryFn: async () => { + const { data } = + await coreApiClient.plugin.extensionDefinition.listExtensionDefinition({ + fieldSelector: [ + `spec.extensionPointName=${extensionPointDefinition.value?.metadata.name}`, + ], + }); + return data; + }, + enabled: computed(() => !!extensionPointDefinition.value), + }); +} diff --git a/console-src/modules/system/plugins/composables/use-plugin.ts b/console-src/modules/system/plugins/composables/use-plugin.ts new file mode 100644 index 0000000..0c01c9c --- /dev/null +++ b/console-src/modules/system/plugins/composables/use-plugin.ts @@ -0,0 +1,380 @@ +import { usePluginModuleStore } from "@/stores/plugin"; +import { usePermission } from "@/utils/permission"; +import { + PluginStatusPhaseEnum, + consoleApiClient, + coreApiClient, + type Plugin, + type SettingForm, +} from "@halo-dev/api-client"; +import { Dialog, Toast } from "@halo-dev/components"; +import type { PluginTab } from "@halo-dev/console-shared"; +import { useMutation, useQuery } from "@tanstack/vue-query"; +import { useRouteQuery } from "@vueuse/router"; +import type { ComputedRef, Ref } from "vue"; +import { computed, markRaw, ref } from "vue"; +import { useI18n } from "vue-i18n"; +import DetailTab from "../components/tabs/Detail.vue"; +import SettingTab from "../components/tabs/Setting.vue"; + +interface usePluginLifeCycleReturn { + isStarted: ComputedRef; + getStatusDotState: () => string; + getStatusMessage: () => string | undefined; + changeStatus: () => void; + changingStatus: Ref; + uninstall: (deleteExtensions?: boolean) => void; +} + +export function usePluginLifeCycle( + plugin?: Ref +): usePluginLifeCycleReturn { + const { t } = useI18n(); + + const isStarted = computed(() => { + return ( + plugin?.value?.status?.phase === PluginStatusPhaseEnum.Started && + plugin.value?.spec.enabled + ); + }); + + const getStatusDotState = () => { + const { phase } = plugin?.value?.status || {}; + const { enabled } = plugin?.value?.spec || {}; + + if (enabled && phase === PluginStatusPhaseEnum.Failed) { + return "error"; + } + + if (phase === PluginStatusPhaseEnum.Disabling) { + return "warning"; + } + + return "default"; + }; + + const getStatusMessage = () => { + if (!plugin?.value) return; + + const { phase } = plugin.value.status || {}; + + if ( + phase === PluginStatusPhaseEnum.Failed || + phase === PluginStatusPhaseEnum.Disabling + ) { + const lastCondition = plugin.value.status?.conditions?.[0]; + + return ( + [lastCondition?.reason, lastCondition?.message] + .filter(Boolean) + .join(": ") || "Unknown" + ); + } + + // Starting up + if ( + phase !== (PluginStatusPhaseEnum.Started || PluginStatusPhaseEnum.Failed) + ) { + return t("core.common.status.starting_up"); + } + }; + + const { isLoading: changingStatus, mutate: changeStatus } = useMutation({ + mutationKey: ["change-plugin-status"], + mutationFn: async () => { + if (!plugin?.value) return; + + const { enabled } = plugin.value.spec; + + return await consoleApiClient.plugin.plugin.changePluginRunningState({ + name: plugin.value.metadata.name, + pluginRunningStateRequest: { + enable: !enabled, + }, + }); + }, + retry: 3, + retryDelay: 1000, + onSuccess() { + window.location.reload(); + }, + }); + + const uninstall = (deleteExtensions?: boolean) => { + if (!plugin?.value) return; + + const { enabled } = plugin.value.spec; + + Dialog.warning({ + title: `${ + deleteExtensions + ? t("core.plugin.operations.uninstall_and_delete_config.title") + : t("core.plugin.operations.uninstall.title") + }`, + description: `${ + enabled + ? t("core.plugin.operations.uninstall_when_enabled.description") + : t("core.common.dialog.descriptions.cannot_be_recovered") + }`, + confirmType: "danger", + confirmText: `${ + enabled + ? t("core.plugin.operations.uninstall_when_enabled.confirm_text") + : t("core.common.buttons.uninstall") + }`, + cancelText: t("core.common.buttons.cancel"), + onConfirm: async () => { + if (!plugin.value) return; + + try { + await consoleApiClient.plugin.plugin.changePluginRunningState({ + name: plugin.value.metadata.name, + pluginRunningStateRequest: { + enable: false, + }, + }); + + await coreApiClient.plugin.plugin.deletePlugin({ + name: plugin.value.metadata.name, + }); + + // delete plugin setting and configMap + if (deleteExtensions) { + const { settingName, configMapName } = plugin.value.spec; + + if (settingName) { + await coreApiClient.setting.deleteSetting( + { + name: settingName, + }, + { + mute: true, + } + ); + } + + if (configMapName) { + await coreApiClient.configMap.deleteConfigMap( + { + name: configMapName, + }, + { + mute: true, + } + ); + } + } + + Toast.success(t("core.common.toast.uninstall_success")); + } catch (e) { + console.error("Failed to uninstall plugin", e); + } finally { + window.location.reload(); + } + }, + }); + }; + + return { + isStarted, + getStatusDotState, + getStatusMessage, + changeStatus, + changingStatus, + uninstall, + }; +} + +export function usePluginBatchOperations(names: Ref) { + const { t } = useI18n(); + + function handleUninstallInBatch(deleteExtensions: boolean) { + Dialog.warning({ + title: `${ + deleteExtensions + ? t( + "core.plugin.operations.uninstall_and_delete_config_in_batch.title" + ) + : t("core.plugin.operations.uninstall_in_batch.title") + }`, + description: t("core.common.dialog.descriptions.cannot_be_recovered"), + confirmType: "danger", + confirmText: t("core.common.buttons.uninstall"), + cancelText: t("core.common.buttons.cancel"), + onConfirm: async () => { + try { + for (let i = 0; i < names.value.length; i++) { + await coreApiClient.plugin.plugin.deletePlugin({ + name: names.value[i], + }); + + if (deleteExtensions) { + const { data: plugin } = + await coreApiClient.plugin.plugin.getPlugin({ + name: names.value[i], + }); + + const { settingName, configMapName } = plugin.spec; + + if (settingName) { + await coreApiClient.setting.deleteSetting( + { + name: settingName, + }, + { + mute: true, + } + ); + } + + if (configMapName) { + await coreApiClient.configMap.deleteConfigMap( + { + name: configMapName, + }, + { + mute: true, + } + ); + } + } + } + + window.location.reload(); + } catch (e) { + console.error("Failed to uninstall plugin in batch", e); + } + }, + }); + } + + function handleChangeStatusInBatch(enabled: boolean) { + Dialog.info({ + title: enabled + ? t("core.plugin.operations.change_status_in_batch.activate_title") + : t("core.plugin.operations.change_status_in_batch.inactivate_title"), + confirmText: t("core.common.buttons.confirm"), + cancelText: t("core.common.buttons.cancel"), + onConfirm: async () => { + try { + for (let i = 0; i < names.value.length; i++) { + await consoleApiClient.plugin.plugin.changePluginRunningState({ + name: names.value[i], + pluginRunningStateRequest: { + enable: enabled, + }, + }); + } + + window.location.reload(); + } catch (e) { + console.error("Failed to change plugin status in batch", e); + } + }, + }); + } + + return { handleUninstallInBatch, handleChangeStatusInBatch }; +} + +export function usePluginDetailTabs( + pluginName: Ref, + recordsActiveTab: boolean +) { + const { currentUserHasPermission } = usePermission(); + const { t } = useI18n(); + + const initialTabs = [ + { + id: "detail", + label: t("core.plugin.tabs.detail"), + component: markRaw(DetailTab), + }, + ]; + + const tabs = ref(initialTabs); + const activeTab = recordsActiveTab + ? useRouteQuery("tab", tabs.value[0].id) + : ref(tabs.value[0].id); + + const { data: plugin } = useQuery({ + queryKey: ["plugin", pluginName], + queryFn: async () => { + const { data } = await coreApiClient.plugin.plugin.getPlugin({ + name: pluginName.value as string, + }); + return data; + }, + async onSuccess(data) { + if ( + !data.spec.settingName || + !currentUserHasPermission(["system:plugins:manage"]) + ) { + tabs.value = [...initialTabs, ...(await getTabsFromExtensions())]; + } + }, + }); + + const { data: setting } = useQuery({ + queryKey: ["plugin-setting", plugin], + queryFn: async () => { + const { data } = await consoleApiClient.plugin.plugin.fetchPluginSetting({ + name: plugin.value?.metadata.name as string, + }); + return data; + }, + enabled: computed(() => { + return ( + !!plugin.value && + !!plugin.value.spec.settingName && + currentUserHasPermission(["system:plugins:manage"]) + ); + }), + async onSuccess(data) { + if (data) { + const { forms } = data.spec; + tabs.value = [ + ...initialTabs, + ...(await getTabsFromExtensions()), + ...forms.map((item: SettingForm) => { + return { + id: item.group, + label: item.label || "", + component: markRaw(SettingTab), + }; + }), + ] as PluginTab[]; + } + }, + }); + + async function getTabsFromExtensions() { + const { pluginModuleMap } = usePluginModuleStore(); + + const currentPluginModule = pluginModuleMap[pluginName.value as string]; + + if (!currentPluginModule) { + return []; + } + + const callbackFunction = + currentPluginModule?.extensionPoints?.["plugin:self:tabs:create"]; + + if (typeof callbackFunction !== "function") { + return []; + } + + const pluginTabs = await callbackFunction(); + + return pluginTabs.filter((tab) => { + return currentUserHasPermission(tab.permissions); + }); + } + + return { + plugin, + setting, + tabs, + activeTab, + }; +} diff --git a/console-src/modules/system/plugins/constants/index.ts b/console-src/modules/system/plugins/constants/index.ts new file mode 100644 index 0000000..5292ff6 --- /dev/null +++ b/console-src/modules/system/plugins/constants/index.ts @@ -0,0 +1,2 @@ +export const PLUGIN_ALREADY_EXISTS_TYPE = + "https://halo.run/probs/plugin-alreay-exists"; diff --git a/console-src/modules/system/plugins/module.ts b/console-src/modules/system/plugins/module.ts new file mode 100644 index 0000000..ef511aa --- /dev/null +++ b/console-src/modules/system/plugins/module.ts @@ -0,0 +1,59 @@ +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import { IconPlug } from "@halo-dev/components"; +import { definePlugin } from "@halo-dev/console-shared"; +import { markRaw } from "vue"; +import type { RouteRecordRaw } from "vue-router"; +import PluginDetail from "./PluginDetail.vue"; +import PluginExtensionPointSettings from "./PluginExtensionPointSettings.vue"; +import PluginList from "./PluginList.vue"; +import PluginDetailModal from "./components/PluginDetailModal.vue"; + +export default definePlugin({ + components: { + PluginDetailModal, + }, + routes: [ + { + path: "/plugins", + name: "PluginsRoot", + component: BasicLayout, + meta: { + title: "core.plugin.title", + searchable: true, + permissions: ["system:plugins:view"], + menu: { + name: "core.sidebar.menu.items.plugins", + group: "system", + icon: markRaw(IconPlug), + priority: 0, + }, + }, + children: [ + { + path: "", + name: "Plugins", + component: PluginList, + }, + { + path: "extension-point-settings", + name: "PluginExtensionPointSettings", + component: PluginExtensionPointSettings, + meta: { + title: "core.plugin.extension-settings.title", + hideFooter: true, + permissions: ["*"], + }, + }, + { + path: ":name", + name: "PluginDetail", + component: PluginDetail, + meta: { + title: "core.plugin.detail.title", + permissions: ["system:plugins:view"], + }, + }, + ], + } as RouteRecordRaw, + ], +}); diff --git a/console-src/modules/system/plugins/types/index.ts b/console-src/modules/system/plugins/types/index.ts new file mode 100644 index 0000000..96de821 --- /dev/null +++ b/console-src/modules/system/plugins/types/index.ts @@ -0,0 +1,10 @@ +export interface PluginInstallationErrorResponse { + detail: string; + instance: string; + pluginName: string; + requestId: string; + status: number; + timestamp: string; + title: string; + type: string; +} diff --git a/console-src/modules/system/roles/RoleDetail.vue b/console-src/modules/system/roles/RoleDetail.vue new file mode 100644 index 0000000..d32a916 --- /dev/null +++ b/console-src/modules/system/roles/RoleDetail.vue @@ -0,0 +1,298 @@ + + diff --git a/console-src/modules/system/roles/RoleList.vue b/console-src/modules/system/roles/RoleList.vue new file mode 100644 index 0000000..d39d3a5 --- /dev/null +++ b/console-src/modules/system/roles/RoleList.vue @@ -0,0 +1,325 @@ + + diff --git a/console-src/modules/system/roles/components/RoleEditingModal.vue b/console-src/modules/system/roles/components/RoleEditingModal.vue new file mode 100644 index 0000000..400eba6 --- /dev/null +++ b/console-src/modules/system/roles/components/RoleEditingModal.vue @@ -0,0 +1,280 @@ + + diff --git a/console-src/modules/system/roles/module.ts b/console-src/modules/system/roles/module.ts new file mode 100644 index 0000000..ea88cee --- /dev/null +++ b/console-src/modules/system/roles/module.ts @@ -0,0 +1,35 @@ +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import { definePlugin } from "@halo-dev/console-shared"; +import RoleDetail from "./RoleDetail.vue"; +import RoleList from "./RoleList.vue"; + +export default definePlugin({ + components: {}, + routes: [ + { + path: "/users/roles", + component: BasicLayout, + children: [ + { + path: "", + name: "Roles", + component: RoleList, + meta: { + title: "core.role.title", + searchable: true, + permissions: ["system:roles:view"], + }, + }, + { + path: ":name", + name: "RoleDetail", + component: RoleDetail, + meta: { + title: "core.role.detail.title", + permissions: ["system:roles:view"], + }, + }, + ], + }, + ], +}); diff --git a/console-src/modules/system/settings/SystemSettings.vue b/console-src/modules/system/settings/SystemSettings.vue new file mode 100644 index 0000000..ea4f6df --- /dev/null +++ b/console-src/modules/system/settings/SystemSettings.vue @@ -0,0 +1,104 @@ + + diff --git a/console-src/modules/system/settings/module.ts b/console-src/modules/system/settings/module.ts new file mode 100644 index 0000000..1e10c4a --- /dev/null +++ b/console-src/modules/system/settings/module.ts @@ -0,0 +1,33 @@ +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import { IconSettings } from "@halo-dev/components"; +import { definePlugin } from "@halo-dev/console-shared"; +import { markRaw } from "vue"; +import SystemSettings from "./SystemSettings.vue"; + +export default definePlugin({ + components: {}, + routes: [ + { + path: "/settings", + name: "SettingsRoot", + component: BasicLayout, + meta: { + title: "core.setting.title", + permissions: ["system:settings:view"], + menu: { + name: "core.sidebar.menu.items.settings", + group: "system", + icon: markRaw(IconSettings), + priority: 2, + }, + }, + children: [ + { + path: "", + name: "SystemSetting", + component: SystemSettings, + }, + ], + }, + ], +}); diff --git a/console-src/modules/system/settings/tabs/NotificationSetting.vue b/console-src/modules/system/settings/tabs/NotificationSetting.vue new file mode 100644 index 0000000..5401a1a --- /dev/null +++ b/console-src/modules/system/settings/tabs/NotificationSetting.vue @@ -0,0 +1,105 @@ + + + diff --git a/console-src/modules/system/settings/tabs/Notifications.vue b/console-src/modules/system/settings/tabs/Notifications.vue new file mode 100644 index 0000000..0477bb7 --- /dev/null +++ b/console-src/modules/system/settings/tabs/Notifications.vue @@ -0,0 +1,70 @@ + + + diff --git a/console-src/modules/system/settings/tabs/Setting.vue b/console-src/modules/system/settings/tabs/Setting.vue new file mode 100644 index 0000000..a897b11 --- /dev/null +++ b/console-src/modules/system/settings/tabs/Setting.vue @@ -0,0 +1,103 @@ + + diff --git a/console-src/modules/system/tools/Tools.vue b/console-src/modules/system/tools/Tools.vue new file mode 100644 index 0000000..d755163 --- /dev/null +++ b/console-src/modules/system/tools/Tools.vue @@ -0,0 +1,98 @@ + + + diff --git a/console-src/modules/system/tools/module.ts b/console-src/modules/system/tools/module.ts new file mode 100644 index 0000000..2ea5db0 --- /dev/null +++ b/console-src/modules/system/tools/module.ts @@ -0,0 +1,32 @@ +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import { IconToolsFill } from "@halo-dev/components"; +import { definePlugin } from "@halo-dev/console-shared"; +import { markRaw } from "vue"; +import Tools from "./Tools.vue"; + +export default definePlugin({ + components: {}, + routes: [ + { + path: "/tools", + name: "ToolsRoot", + component: BasicLayout, + meta: { + title: "core.tool.title", + menu: { + name: "core.sidebar.menu.items.tools", + group: "system", + icon: markRaw(IconToolsFill), + priority: 5, + }, + }, + children: [ + { + path: "", + name: "Tools", + component: Tools, + }, + ], + }, + ], +}); diff --git a/console-src/modules/system/users/UserDetail.vue b/console-src/modules/system/users/UserDetail.vue new file mode 100644 index 0000000..798d01d --- /dev/null +++ b/console-src/modules/system/users/UserDetail.vue @@ -0,0 +1,180 @@ + + diff --git a/console-src/modules/system/users/UserList.vue b/console-src/modules/system/users/UserList.vue new file mode 100644 index 0000000..e438fc2 --- /dev/null +++ b/console-src/modules/system/users/UserList.vue @@ -0,0 +1,554 @@ + + diff --git a/console-src/modules/system/users/components/GrantPermissionModal.vue b/console-src/modules/system/users/components/GrantPermissionModal.vue new file mode 100644 index 0000000..7330947 --- /dev/null +++ b/console-src/modules/system/users/components/GrantPermissionModal.vue @@ -0,0 +1,78 @@ + + + diff --git a/console-src/modules/system/users/components/UserCreationModal.vue b/console-src/modules/system/users/components/UserCreationModal.vue new file mode 100644 index 0000000..5084f5e --- /dev/null +++ b/console-src/modules/system/users/components/UserCreationModal.vue @@ -0,0 +1,150 @@ + + diff --git a/console-src/modules/system/users/components/UserEditingModal.vue b/console-src/modules/system/users/components/UserEditingModal.vue new file mode 100644 index 0000000..e7adb37 --- /dev/null +++ b/console-src/modules/system/users/components/UserEditingModal.vue @@ -0,0 +1,171 @@ + + diff --git a/console-src/modules/system/users/components/UserPasswordChangeModal.vue b/console-src/modules/system/users/components/UserPasswordChangeModal.vue new file mode 100644 index 0000000..1af2058 --- /dev/null +++ b/console-src/modules/system/users/components/UserPasswordChangeModal.vue @@ -0,0 +1,115 @@ + + + diff --git a/console-src/modules/system/users/composables/use-user.ts b/console-src/modules/system/users/composables/use-user.ts new file mode 100644 index 0000000..b35a246 --- /dev/null +++ b/console-src/modules/system/users/composables/use-user.ts @@ -0,0 +1,45 @@ +import type { User } from "@halo-dev/api-client"; +import { coreApiClient } from "@halo-dev/api-client"; +import type { Ref } from "vue"; +import { onMounted, ref } from "vue"; + +interface useUserFetchReturn { + users: Ref; + loading: Ref; + handleFetchUsers: () => void; +} + +export function useUserFetch(options?: { + fetchOnMounted: boolean; +}): useUserFetchReturn { + const { fetchOnMounted } = options || {}; + + const users = ref([] as User[]); + const loading = ref(false); + + const ANONYMOUSUSER_NAME = "anonymousUser"; + + const handleFetchUsers = async () => { + try { + loading.value = true; + const { data } = await coreApiClient.user.listUser({ + fieldSelector: [`name!=${ANONYMOUSUSER_NAME}`], + }); + users.value = data.items; + } catch (e) { + console.error("Failed to fetch users", e); + } finally { + loading.value = false; + } + }; + + onMounted(() => { + fetchOnMounted && handleFetchUsers(); + }); + + return { + users, + loading, + handleFetchUsers, + }; +} diff --git a/console-src/modules/system/users/module.ts b/console-src/modules/system/users/module.ts new file mode 100644 index 0000000..1443dab --- /dev/null +++ b/console-src/modules/system/users/module.ts @@ -0,0 +1,49 @@ +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import { IconUserSettings } from "@halo-dev/components"; +import { definePlugin } from "@halo-dev/console-shared"; +import { markRaw } from "vue"; +import UserDetail from "./UserDetail.vue"; +import UserList from "./UserList.vue"; +import NotificationWidget from "./widgets/NotificationWidget.vue"; +import UserStatsWidget from "./widgets/UserStatsWidget.vue"; + +export default definePlugin({ + components: { + UserStatsWidget, + NotificationWidget, + }, + routes: [ + { + path: "/users", + name: "UsersRoot", + component: BasicLayout, + meta: { + title: "core.user.title", + searchable: true, + permissions: ["system:users:view"], + menu: { + name: "core.sidebar.menu.items.users", + group: "system", + icon: markRaw(IconUserSettings), + priority: 1, + mobile: true, + }, + }, + children: [ + { + path: "", + name: "Users", + component: UserList, + }, + { + path: ":name", + name: "UserDetail", + component: UserDetail, + meta: { + title: "core.user.detail.title", + }, + }, + ], + }, + ], +}); diff --git a/console-src/modules/system/users/tabs/Detail.vue b/console-src/modules/system/users/tabs/Detail.vue new file mode 100644 index 0000000..49f97be --- /dev/null +++ b/console-src/modules/system/users/tabs/Detail.vue @@ -0,0 +1,89 @@ + + diff --git a/console-src/modules/system/users/widgets/NotificationWidget.vue b/console-src/modules/system/users/widgets/NotificationWidget.vue new file mode 100644 index 0000000..007b08f --- /dev/null +++ b/console-src/modules/system/users/widgets/NotificationWidget.vue @@ -0,0 +1,107 @@ + + + diff --git a/console-src/modules/system/users/widgets/RecentLoginWidget.vue b/console-src/modules/system/users/widgets/RecentLoginWidget.vue new file mode 100644 index 0000000..124568d --- /dev/null +++ b/console-src/modules/system/users/widgets/RecentLoginWidget.vue @@ -0,0 +1,46 @@ + + diff --git a/console-src/modules/system/users/widgets/UserStatsWidget.vue b/console-src/modules/system/users/widgets/UserStatsWidget.vue new file mode 100644 index 0000000..be65bab --- /dev/null +++ b/console-src/modules/system/users/widgets/UserStatsWidget.vue @@ -0,0 +1,28 @@ + + diff --git a/console-src/router/constant.ts b/console-src/router/constant.ts new file mode 100644 index 0000000..d7c2580 --- /dev/null +++ b/console-src/router/constant.ts @@ -0,0 +1,29 @@ +import type { MenuGroupType } from "@halo-dev/console-shared"; + +export const coreMenuGroups: MenuGroupType[] = [ + { + id: "dashboard", + name: undefined, + priority: 0, + }, + { + id: "content", + name: "core.sidebar.menu.groups.content", + priority: 1, + }, + { + id: "interface", + name: "core.sidebar.menu.groups.interface", + priority: 2, + }, + { + id: "system", + name: "core.sidebar.menu.groups.system", + priority: 3, + }, + { + id: "tool", + name: "core.sidebar.menu.groups.tool", + priority: 4, + }, +]; diff --git a/console-src/router/guards/auth-check.ts b/console-src/router/guards/auth-check.ts new file mode 100644 index 0000000..203ae12 --- /dev/null +++ b/console-src/router/guards/auth-check.ts @@ -0,0 +1,82 @@ +import { rbacAnnotations } from "@/constants/annotations"; +import { useUserStore } from "@/stores/user"; +import type { Router } from "vue-router"; + +const whiteList = ["Setup", "Login", "Binding", "ResetPassword", "Redirect"]; + +export function setupAuthCheckGuard(router: Router) { + router.beforeEach((to, from, next) => { + const userStore = useUserStore(); + + if (userStore.isAnonymous) { + if (whiteList.includes(to.name as string)) { + next(); + return; + } + + next({ + name: "Login", + query: { + redirect_uri: encodeURIComponent(window.location.href), + }, + }); + return; + } else { + if (to.name === "Login") { + if (to.query.redirect_uri) { + next({ + name: "Redirect", + query: { + redirect_uri: to.query.redirect_uri, + }, + }); + return; + } + + const roleHasRedirectOnLogin = userStore.currentRoles?.find( + (role) => + role.metadata.annotations?.[rbacAnnotations.REDIRECT_ON_LOGIN] + ); + + if (roleHasRedirectOnLogin) { + window.location.href = + roleHasRedirectOnLogin.metadata.annotations?.[ + rbacAnnotations.REDIRECT_ON_LOGIN + ] || "/uc"; + return; + } + + next({ + name: "Dashboard", + }); + return; + } + + if (to.name && whiteList.includes(to.name as string)) { + next(); + return; + } + + // Check allow access console + const { currentRoles } = userStore; + + const hasDisallowAccessConsoleRole = currentRoles?.some((role) => { + return ( + role.metadata.annotations?.[ + rbacAnnotations.DISALLOW_ACCESS_CONSOLE + ] === "true" + ); + }); + + if (hasDisallowAccessConsoleRole) { + window.location.href = "/uc"; + return; + } + + next(); + return; + } + + next(); + }); +} diff --git a/console-src/router/guards/check-states.ts b/console-src/router/guards/check-states.ts new file mode 100644 index 0000000..442c24d --- /dev/null +++ b/console-src/router/guards/check-states.ts @@ -0,0 +1,37 @@ +import { useGlobalInfoStore } from "@/stores/global-info"; +import { useUserStore } from "@/stores/user"; +import type { Router } from "vue-router"; + +export function setupCheckStatesGuard(router: Router) { + router.beforeEach(async (to, from, next) => { + const userStore = useUserStore(); + const { globalInfo } = useGlobalInfoStore(); + const { userInitialized, dataInitialized } = globalInfo || {}; + + if (to.name === "Setup" && userInitialized) { + next({ name: "Dashboard" }); + return; + } + + if (to.name === "SetupInitialData" && dataInitialized) { + next({ name: "Dashboard" }); + return; + } + + if (userInitialized === false && to.name !== "Setup") { + next({ name: "Setup" }); + return; + } + + if ( + dataInitialized === false && + !userStore.isAnonymous && + to.name !== "SetupInitialData" + ) { + next({ name: "SetupInitialData" }); + return; + } + + next(); + }); +} diff --git a/console-src/router/guards/permission.ts b/console-src/router/guards/permission.ts new file mode 100644 index 0000000..0094f9c --- /dev/null +++ b/console-src/router/guards/permission.ts @@ -0,0 +1,22 @@ +import { useRoleStore } from "@/stores/role"; +import { hasPermission } from "@/utils/permission"; +import type { Router } from "vue-router"; + +export function setupPermissionGuard(router: Router) { + router.beforeEach((to, from, next) => { + const roleStore = useRoleStore(); + const { uiPermissions } = roleStore.permissions; + const { meta } = to; + if (meta && meta.permissions) { + const flag = hasPermission( + Array.from(uiPermissions), + meta.permissions as string[], + true + ); + if (!flag) { + next({ name: "Forbidden" }); + } + } + next(); + }); +} diff --git a/console-src/router/index.ts b/console-src/router/index.ts new file mode 100644 index 0000000..d8afd9b --- /dev/null +++ b/console-src/router/index.ts @@ -0,0 +1,29 @@ +import routesConfig from "@console/router/routes.config"; +import { + createRouter, + createWebHistory, + type RouteLocationNormalized, + type RouteLocationNormalizedLoaded, +} from "vue-router"; +import { setupAuthCheckGuard } from "./guards/auth-check"; +import { setupCheckStatesGuard } from "./guards/check-states"; +import { setupPermissionGuard } from "./guards/permission"; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: routesConfig, + scrollBehavior: ( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ) => { + if (to.name !== from.name) { + return { left: 0, top: 0 }; + } + }, +}); + +setupCheckStatesGuard(router); +setupAuthCheckGuard(router); +setupPermissionGuard(router); + +export default router; diff --git a/console-src/router/routes.config.ts b/console-src/router/routes.config.ts new file mode 100644 index 0000000..7238ae3 --- /dev/null +++ b/console-src/router/routes.config.ts @@ -0,0 +1,101 @@ +import GatewayLayout from "@/layouts/GatewayLayout.vue"; +import Forbidden from "@/views/exceptions/Forbidden.vue"; +import NotFound from "@/views/exceptions/NotFound.vue"; +import BasicLayout from "@console/layouts/BasicLayout.vue"; +import Binding from "@console/views/system/Binding.vue"; +import Login from "@console/views/system/Login.vue"; +import Redirect from "@console/views/system/Redirect.vue"; +import ResetPassword from "@console/views/system/ResetPassword.vue"; +import Setup from "@console/views/system/Setup.vue"; +import SetupInitialData from "@console/views/system/SetupInitialData.vue"; +import type { RouteRecordRaw } from "vue-router"; + +export const routes: Array = [ + { + path: "/:pathMatch(.*)*", + component: BasicLayout, + children: [{ path: "", name: "NotFound", component: NotFound }], + }, + { + path: "/403", + component: BasicLayout, + children: [ + { + path: "", + name: "Forbidden", + component: Forbidden, + }, + ], + }, + { + path: "/login", + component: GatewayLayout, + children: [ + { + path: "", + name: "Login", + component: Login, + meta: { + title: "core.login.title", + }, + }, + ], + }, + { + path: "/binding/:provider", + component: GatewayLayout, + children: [ + { + path: "", + name: "Binding", + component: Binding, + meta: { + title: "core.binding.title", + }, + }, + ], + }, + { + path: "/setup", + component: GatewayLayout, + children: [ + { + path: "", + name: "Setup", + component: Setup, + meta: { + title: "core.setup.title", + }, + }, + ], + }, + { + path: "/setup-initial-data", + name: "SetupInitialData", + component: SetupInitialData, + meta: { + title: "core.setup.title", + }, + }, + { + path: "/redirect", + name: "Redirect", + component: Redirect, + }, + { + path: "/reset-password", + component: GatewayLayout, + children: [ + { + path: "", + name: "ResetPassword", + component: ResetPassword, + meta: { + title: "core.reset_password.title", + }, + }, + ], + }, +]; + +export default routes; diff --git a/console-src/setup/setupModules.ts b/console-src/setup/setupModules.ts new file mode 100644 index 0000000..00aaa20 --- /dev/null +++ b/console-src/setup/setupModules.ts @@ -0,0 +1,145 @@ +import { i18n } from "@/locales"; +import { usePluginModuleStore } from "@/stores/plugin"; +import { loadStyle } from "@/utils/load-style"; +import modules from "@console/modules"; +import router from "@console/router"; +import { Toast } from "@halo-dev/components"; +import type { PluginModule, RouteRecordAppend } from "@halo-dev/console-shared"; +import { useScriptTag } from "@vueuse/core"; +import type { App } from "vue"; +import type { RouteRecordRaw } from "vue-router"; + +export function setupCoreModules(app: App) { + modules.forEach((module) => registerModule(app, module, true)); +} + +export async function setupPluginModules(app: App) { + const pluginModuleStore = usePluginModuleStore(); + + try { + await loadPluginBundle(); + await registerEnabledPlugins(app, pluginModuleStore); + await loadPluginStyles(); + } catch (error) { + handleError(error); + } +} + +async function loadPluginBundle() { + const { load } = useScriptTag( + `/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js?t=${Date.now()}` + ); + await load(); +} + +async function registerEnabledPlugins( + app: App, + pluginModuleStore: ReturnType +) { + const enabledPlugins = window["enabledPlugins"] as { + name: string; + value: string; + }[]; + + if (enabledPlugins) { + enabledPlugins.forEach((plugin) => + registerPluginIfAvailable(app, pluginModuleStore, plugin.name) + ); + } + + // @Deprecated: Compatibility solution, will be removed in the future + const enabledPluginNames = window["enabledPluginNames"] as string[]; + if (enabledPluginNames) { + enabledPluginNames.forEach((name) => + registerPluginIfAvailable(app, pluginModuleStore, name) + ); + } +} + +function registerPluginIfAvailable( + app: App, + pluginModuleStore: ReturnType, + name: string +) { + const module = window[name]; + if (module) { + registerModule(app, module, false); + pluginModuleStore.registerPluginModule(name, module); + } +} + +async function loadPluginStyles() { + await loadStyle( + `/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css?t=${Date.now()}` + ); +} + +function handleError(error: unknown) { + const message = + error instanceof Error && error.message.includes("style") + ? i18n.global.t("core.plugin.loader.toast.style_load_failed") + : i18n.global.t("core.plugin.loader.toast.entry_load_failed"); + + console.error(message, error); + Toast.error(message); +} + +function registerModule(app: App, pluginModule: PluginModule, core: boolean) { + if (pluginModule.components) { + Object.keys(pluginModule.components).forEach((key) => { + const component = pluginModule.components?.[key]; + if (component) { + app.component(key, component); + } + }); + } + + if (pluginModule.routes) { + if (!Array.isArray(pluginModule.routes)) { + return; + } + + resetRouteMeta(pluginModule.routes); + + for (const route of pluginModule.routes) { + if ("parentName" in route) { + const parentRoute = router + .getRoutes() + .find((item) => item.name === route.parentName); + if (parentRoute) { + router.removeRoute(route.parentName); + parentRoute.children = [...parentRoute.children, route.route]; + router.addRoute(parentRoute); + } + } else { + router.addRoute(route); + } + } + } + + function resetRouteMeta(routes: RouteRecordRaw[] | RouteRecordAppend[]) { + for (const route of routes) { + if ("parentName" in route) { + if (route.route.meta?.menu) { + route.route.meta = { + ...route.route.meta, + core, + }; + } + if (route.route.children) { + resetRouteMeta(route.route.children); + } + } else { + if (route.meta?.menu) { + route.meta = { + ...route.meta, + core, + }; + } + if (route.children) { + resetRouteMeta(route.children); + } + } + } + } +} diff --git a/console-src/stores/system-configmap.ts b/console-src/stores/system-configmap.ts new file mode 100644 index 0000000..18ed50c --- /dev/null +++ b/console-src/stores/system-configmap.ts @@ -0,0 +1,29 @@ +import type { ConfigMap } from "@halo-dev/api-client"; +import { coreApiClient } from "@halo-dev/api-client"; +import { defineStore } from "pinia"; + +interface SystemConfigMapState { + configMap?: ConfigMap; +} + +export const useSystemConfigMapStore = defineStore({ + id: "system-configmap", + state: (): SystemConfigMapState => ({ + configMap: undefined, + }), + actions: { + async fetchSystemConfigMap() { + try { + const { data } = await coreApiClient.configMap.getConfigMap( + { + name: "system", + }, + { mute: true } + ); + this.configMap = data; + } catch (error) { + console.error("Failed to fetch system configMap", error); + } + }, + }, +}); diff --git a/console-src/stores/theme.ts b/console-src/stores/theme.ts new file mode 100644 index 0000000..1b55c82 --- /dev/null +++ b/console-src/stores/theme.ts @@ -0,0 +1,31 @@ +import { usePermission } from "@/utils/permission"; +import type { Theme } from "@halo-dev/api-client"; +import { consoleApiClient } from "@halo-dev/api-client"; +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export const useThemeStore = defineStore("theme", () => { + const activatedTheme = ref(); + + const { currentUserHasPermission } = usePermission(); + + async function fetchActivatedTheme() { + if (!currentUserHasPermission(["system:themes:view"])) { + return; + } + + try { + const { data } = await consoleApiClient.theme.theme.fetchActivatedTheme({ + mute: true, + }); + + if (data) { + activatedTheme.value = data; + } + } catch (e) { + console.error("Failed to fetch active theme", e); + } + } + + return { activatedTheme, fetchActivatedTheme }; +}); diff --git a/console-src/views/system/Binding.vue b/console-src/views/system/Binding.vue new file mode 100644 index 0000000..aed3ea4 --- /dev/null +++ b/console-src/views/system/Binding.vue @@ -0,0 +1,87 @@ + + diff --git a/console-src/views/system/Login.vue b/console-src/views/system/Login.vue new file mode 100644 index 0000000..76d70ba --- /dev/null +++ b/console-src/views/system/Login.vue @@ -0,0 +1,85 @@ + + diff --git a/console-src/views/system/Redirect.vue b/console-src/views/system/Redirect.vue new file mode 100644 index 0000000..639a4ff --- /dev/null +++ b/console-src/views/system/Redirect.vue @@ -0,0 +1,41 @@ + + diff --git a/console-src/views/system/ResetPassword.vue b/console-src/views/system/ResetPassword.vue new file mode 100644 index 0000000..3486127 --- /dev/null +++ b/console-src/views/system/ResetPassword.vue @@ -0,0 +1,81 @@ + + + diff --git a/console-src/views/system/Setup.vue b/console-src/views/system/Setup.vue new file mode 100644 index 0000000..9852f42 --- /dev/null +++ b/console-src/views/system/Setup.vue @@ -0,0 +1,124 @@ + + + diff --git a/console-src/views/system/SetupInitialData.vue b/console-src/views/system/SetupInitialData.vue new file mode 100644 index 0000000..ee8c9fb --- /dev/null +++ b/console-src/views/system/SetupInitialData.vue @@ -0,0 +1,165 @@ + + + diff --git a/console-src/views/system/setup-data/category.json b/console-src/views/system/setup-data/category.json new file mode 100644 index 0000000..d65a2fa --- /dev/null +++ b/console-src/views/system/setup-data/category.json @@ -0,0 +1,16 @@ +{ + "spec": { + "displayName": "默认分类", + "slug": "default", + "description": "这是你的默认分类,如不需要,删除即可。", + "cover": "", + "template": "", + "priority": 0, + "children": [] + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Category", + "metadata": { + "name": "76514a40-6ef1-4ed9-b58a-e26945bde3ca" + } +} diff --git a/console-src/views/system/setup-data/menu-items.json b/console-src/views/system/setup-data/menu-items.json new file mode 100644 index 0000000..603f19d --- /dev/null +++ b/console-src/views/system/setup-data/menu-items.json @@ -0,0 +1,62 @@ +[ + { + "spec": { + "displayName": "首页", + "href": "/", + "children": [], + "priority": 0 + }, + "apiVersion": "v1alpha1", + "kind": "MenuItem", + "metadata": { "name": "88c3f10b-321c-4092-86a8-70db00251b74" } + }, + { + "spec": { + "children": [], + "priority": 1, + "targetRef": { + "group": "content.halo.run", + "version": "v1alpha1", + "kind": "Post", + "name": "5152aea5-c2e8-4717-8bba-2263d46e19d5" + } + }, + "apiVersion": "v1alpha1", + "kind": "MenuItem", + "metadata": { "name": "c4c814d1-0c2c-456b-8c96-4864965fee94" } + }, + { + "spec": { + "displayName": "", + "href": "", + "children": [], + "priority": 2, + "targetRef": { + "group": "content.halo.run", + "version": "v1alpha1", + "kind": "Tag", + "name": "c33ceabb-d8f1-4711-8991-bb8f5c92ad7c" + } + }, + "apiVersion": "v1alpha1", + "kind": "MenuItem", + "metadata": { "name": "35869bd3-33b5-448b-91ee-cf6517a59644" } + }, + { + "spec": { + "displayName": "", + "href": "", + "children": [], + "priority": 3, + "targetRef": { + "group": "content.halo.run", + "version": "v1alpha1", + "kind": "SinglePage", + "name": "373a5f79-f44f-441a-9df1-85a4f553ece8" + } + }, + "apiVersion": "v1alpha1", + "kind": "MenuItem", + "metadata": { "name": "b0d041fa-dc99-48f6-a193-8604003379cf" } + } +] diff --git a/console-src/views/system/setup-data/menu.json b/console-src/views/system/setup-data/menu.json new file mode 100644 index 0000000..c1526b5 --- /dev/null +++ b/console-src/views/system/setup-data/menu.json @@ -0,0 +1,14 @@ +{ + "spec": { + "displayName": "主菜单", + "menuItems": [ + "88c3f10b-321c-4092-86a8-70db00251b74", + "c4c814d1-0c2c-456b-8c96-4864965fee94", + "35869bd3-33b5-448b-91ee-cf6517a59644", + "b0d041fa-dc99-48f6-a193-8604003379cf" + ] + }, + "apiVersion": "v1alpha1", + "kind": "Menu", + "metadata": { "name": "primary" } +} diff --git a/console-src/views/system/setup-data/post.json b/console-src/views/system/setup-data/post.json new file mode 100644 index 0000000..d3f2755 --- /dev/null +++ b/console-src/views/system/setup-data/post.json @@ -0,0 +1,35 @@ +{ + "post": { + "spec": { + "title": "Hello Halo", + "slug": "hello-halo", + "template": "", + "cover": "", + "deleted": false, + "publish": false, + "publishTime": "", + "pinned": false, + "allowComment": true, + "visible": "PUBLIC", + "version": 1, + "priority": 0, + "excerpt": { + "autoGenerate": false, + "raw": "如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。" + }, + "categories": ["76514a40-6ef1-4ed9-b58a-e26945bde3ca"], + "tags": ["c33ceabb-d8f1-4711-8991-bb8f5c92ad7c"], + "htmlMetas": [] + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Post", + "metadata": { + "name": "5152aea5-c2e8-4717-8bba-2263d46e19d5" + } + }, + "content": { + "raw": "

Hello Halo

如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。

相关链接

在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

", + "content": "

Hello Halo

如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。

相关链接

在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

", + "rawType": "HTML" + } +} diff --git a/console-src/views/system/setup-data/singlePage.json b/console-src/views/system/setup-data/singlePage.json new file mode 100644 index 0000000..3c605d8 --- /dev/null +++ b/console-src/views/system/setup-data/singlePage.json @@ -0,0 +1,31 @@ +{ + "page": { + "spec": { + "title": "关于", + "slug": "about", + "template": "", + "cover": "", + "deleted": false, + "publish": false, + "publishTime": "", + "pinned": false, + "allowComment": true, + "visible": "PUBLIC", + "version": 1, + "priority": 0, + "excerpt": { + "autoGenerate": false, + "raw": "这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。" + }, + "htmlMetas": [] + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "SinglePage", + "metadata": { "name": "373a5f79-f44f-441a-9df1-85a4f553ece8" } + }, + "content": { + "raw": "

关于页面

这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。

这是一篇自动生成的页面,你可以在后台删除它。

", + "content": "

关于页面

这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。

这是一篇自动生成的页面,你可以在后台删除它。

", + "rawType": "HTML" + } +} diff --git a/console-src/views/system/setup-data/tag.json b/console-src/views/system/setup-data/tag.json new file mode 100644 index 0000000..c82a115 --- /dev/null +++ b/console-src/views/system/setup-data/tag.json @@ -0,0 +1,13 @@ +{ + "spec": { + "displayName": "Halo", + "slug": "halo", + "color": "#ffffff", + "cover": "" + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Tag", + "metadata": { + "name": "c33ceabb-d8f1-4711-8991-bb8f5c92ad7c" + } +} diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000..6ba1987 --- /dev/null +++ b/cypress.json @@ -0,0 +1,3 @@ +{ + "baseUrl": "http://localhost:5050" +} diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 0000000..02e4254 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/integration/example.spec.ts b/cypress/integration/example.spec.ts new file mode 100644 index 0000000..3130c00 --- /dev/null +++ b/cypress/integration/example.spec.ts @@ -0,0 +1,8 @@ +// https://docs.cypress.io/api/introduction/api.html + +describe("My First Test", () => { + it("visits the app root url", () => { + cy.visit("/"); + cy.contains("h1", "You did it!"); + }); +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts new file mode 100644 index 0000000..49dc983 --- /dev/null +++ b/cypress/plugins/index.ts @@ -0,0 +1,19 @@ +/* eslint-env node */ +// *********************************************************** +// This example plugins/index.ts can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +export default ((on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config + return config; +}) as Cypress.PluginConfig; diff --git a/cypress/plugins/tsconfig.json b/cypress/plugins/tsconfig.json new file mode 100644 index 0000000..d815dc3 --- /dev/null +++ b/cypress/plugins/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.node.json", + "include": [ + "./**/*" + ], + "compilerOptions": { + "module": "CommonJS", + "preserveValueImports": false, + "types": [ + "node", + "cypress/types/cypress" + ] + } +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 0000000..119ab03 --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,25 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 0000000..d076cec --- /dev/null +++ b/cypress/support/index.ts @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import "./commands"; + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000..8e5ea04 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@vue/tsconfig/tsconfig.web.json", + "include": [ + "./integration/**/*", + "./support/**/*" + ], + "compilerOptions": { + "isolatedModules": false, + "target": "es5", + "lib": [ + "es5", + "dom" + ], + "types": [ + "cypress" + ] + } +} diff --git a/docs/components/README.md b/docs/components/README.md new file mode 100644 index 0000000..14f3c29 --- /dev/null +++ b/docs/components/README.md @@ -0,0 +1,54 @@ +# Console 组件介绍 + +目前 Console 的组件包含基础组件(`@halo-dev/components`)和 Console 端的业务组件,这两种组件都可以在插件中使用。 + +## 业务组件 + +### AnnotationsForm + +此组件用于为自定义模型设置 annotations 数据,同时支持自定义 key / value 和自定义表单,表单定义方式可以参考: + +使用方式: + +```vue + + + +``` + +其中,kind 和 group 为必填项,分别表示模型的 kind 和 group。 diff --git a/docs/custom-formkit-input/README.md b/docs/custom-formkit-input/README.md new file mode 100644 index 0000000..50e48db --- /dev/null +++ b/docs/custom-formkit-input/README.md @@ -0,0 +1,217 @@ +# 自定义 FormKit 输入组件 + +## 原由 + +目前在 Console 端的所有表单都使用了 FormKit,但 FormKit 内置的 Input 组件并不满足所有的需求,因此需要自定义一些 Input 组件。此外,为了插件和主题能够更加方便的使用系统内的一些数据,所以同样需要自定义一些带数据的选择组件。 + +## 使用方式 + +目前已提供以下类型: + +- `code`: 代码编辑器 + - 参数 + 1. language: 目前支持 `yaml`, `html`, `css`, `javascript`, `json` + 2. height: 编辑器高度,如:`100px` +- `attachment`: 附件选择 + - 参数 + 1. accepts:允许上传的文件类型,如:`image/*` +- `repeater`: 定义一个对象集合,可以让使用者可视化的操作集合。 + - 参数 + 1. min: 最小数量,默认为 `0` + 2. max: 最大数量,默认为 `Infinity`,即无限制。 + 3. addLabel: 添加按钮的文本,默认为 `添加` + 4. addButton: 是否显示添加按钮,默认为 `true` + 5. upControl: 是否显示上移按钮,默认为 `true` + 6. downControl: 是否显示下移按钮,默认为 `true` + 7. insertControl: 是否显示插入按钮,默认为 `true` + 8. removeControl: 是否显示删除按钮,默认为 `true` +- `list`: 动态列表,定义一个数组列表。 + - 参数 + 1. itemType: 列表项的数据类型,用于初始化数据类型,可选参数 `string`, `number`, `boolean`, `object`,默认为 `string` + 2. min: 最小数量,默认为 `0` + 3. max: 最大数量,默认为 `Infinity`,即无限制。 + 4. addLabel: 添加按钮的文本,默认为 `添加` + 5. addButton: 是否显示添加按钮,默认为 `true` + 6. upControl: 是否显示上移按钮,默认为 `true` + 7. downControl: 是否显示下移按钮,默认为 `true` + 8. insertControl: 是否显示插入按钮,默认为 `true` + 9. removeControl: 是否显示删除按钮,默认为 `true` +- `menuCheckbox`:选择一组菜单 +- `menuRadio`:选择一个菜单 +- `menuItemSelect`:选择菜单项 +- `postSelect`:选择文章 +- `singlePageSelect`:选择自定义页面 +- `categorySelect`:选择分类 + - 参数 + 1. multiple: 是否多选,默认为 `false` +- `categoryCheckbox`:选择多个分类 +- `tagSelect`:选择标签 + - 参数 + 1. multiple: 是否多选,默认为 `false` +- `tagCheckbox`:选择多个标签 +- `verificationForm`: 远程验证一组数据是否符合某个规则 + - 参数 + 1. action: 对目标数据进行验证的接口地址 + 2. label: 验证按钮文本 + 3. buttonAttrs: 验证按钮的额外属性 +- `secret`: 用于选择或者管理密钥(Secret) + - 参数 + 1. requiredKey:用于确认所需密钥的字段名称 + +在 Vue 单组件中使用: + +```vue + + + +``` + +在 FormKit Schema 中使用(插件 / 主题设置表单定义): + +```yaml +- $formkit: menuRadio + name: menus + label: 底部菜单组 +``` + +### list + +list 是一个数组类型的输入组件,可以让使用者可视化的操作数组。它支持动态添加、删除、上移、下移、插入数组项等操作。 + +在 Vue SFC 中以组件形式使用: + +```vue + + + +``` + +在 FormKit Schema 中使用: + +```yaml +- $formkit: list + name: users + label: Users + addLabel: Add User + min: 1 + max: 3 + itemType: string + children: + - $formkit: text + index: "$index" + validation: required +``` + +> [!NOTE] +> `list` 组件有且只有一个子节点,并且必须为子节点传递 `index` 属性。若想提供多个字段,则建议使用 `group` 组件包裹。 + +最终得到的数据类似于: + +```json +{ + "users": [ + "Jack", + "John" + ] +} +``` + +### Repeater + +Repeater 是一个集合类型的输入组件,可以让使用者可视化的操作集合。 + +在 Vue SFC 中以组件形式使用: + +```vue + + + +``` + +在 FormKit Schema 中使用: + +```yaml +- $formkit: repeater + name: users + label: Users + addLabel: Add User + min: 1 + max: 3 + items: + - $formkit: text + name: full_name + label: Full Name + validation: required + - $formkit: email + name: email + label: Email + validation: required|email +``` + +最终得到的数据类似于: + +```json +[ + { + "full_name": "Jack", + "email": "jack@example.com" + }, + { + "full_name": "John", + "email": "john@example.com" + } +] +``` diff --git a/docs/extension-points/backup.md b/docs/extension-points/backup.md new file mode 100644 index 0000000..cc77646 --- /dev/null +++ b/docs/extension-points/backup.md @@ -0,0 +1,43 @@ +# 备份页面选项卡扩展点 + +## 原由 + +在 Halo 2.8 中提供了基础备份和恢复的功能,此扩展点是为了提供给插件开发者针对备份扩展更多功能,比如定时备份设置、备份到第三方云存储等。 + +## 定义方式 + +```ts +import { definePlugin } from "@halo-dev/console-shared"; +import BackupStorage from "@/views/BackupStorage.vue"; +import { markRaw } from "vue"; + +export default definePlugin({ + components: {}, + routes: [], + extensionPoints: { + "backup:tabs:create": () => { + return [ + { + id: "storage", + label: "备份位置", + component: markRaw(BackupStorage), + }, + ]; + }, + }, +}); +``` + +BackupTab 类型: + +```ts +import type { Component, Raw } from "vue"; + +export interface BackupTab { + id: string; + label: string; + component: Raw; + permissions?: string[]; +} + +``` diff --git a/docs/extension-points/comment-subject-ref.md b/docs/extension-points/comment-subject-ref.md new file mode 100644 index 0000000..5c3e467 --- /dev/null +++ b/docs/extension-points/comment-subject-ref.md @@ -0,0 +1,59 @@ +# 评论来源显示拓展点 + +在 Console 中,评论管理列表的评论来源默认仅支持显示来自文章和页面的评论,如果其他插件中的业务模块也使用了评论,那么就可以通过该拓展点来扩展评论来源的显示。 + +## 定义方式 + +假设以文章为例: + +```ts +import { definePlugin } from "@halo-dev/console-shared"; +import type { CommentSubjectRefResult } from "@halo-dev/console-shared"; +import type { Extension } from "@halo-dev/api-client"; +import type { Post } from "./types"; + +export default definePlugin({ + components: {}, + extensionPoints: { + "comment:subject-ref:create": () => { + return [ + { + kind: "Post", + group: "post.halo.run", + resolve: (subject: Extension): CommentSubjectRefResult => { + const post = subject as Post; + return { + label: "文章", + title: post.spec.title, + externalUrl: post.status.permalink, + route: { + name: "PostEditor", + params: { + name: post.metadata.name + } + }, + }; + }, + }, + ]; + }, + }, +}); +``` + +类型定义如下: + +```ts +type CommentSubjectRefProvider = { + kind: string; // 自定义模型的类型 + group: string; // 自定义模型的分组 + resolve: (subject: Extension) => CommentSubjectRefResult; +} + +interface CommentSubjectRefResult { + label: string; // 来源名称(类型) + title: string; // 来源标题 + route?: RouteLocationRaw; // Console 的路由,可以设置为来源的详情或者编辑页面 + externalUrl?: string; // 访问地址,可以设置为前台资源的访问地址 +} +``` diff --git a/docs/extension-points/default-editor–extension.md b/docs/extension-points/default-editor–extension.md new file mode 100644 index 0000000..b715ae7 --- /dev/null +++ b/docs/extension-points/default-editor–extension.md @@ -0,0 +1,19 @@ +# 默认编辑器扩展点 + +该扩展点用于扩展默认编辑器的功能,包括 Tiptap Extension,以及工具栏、悬浮工具栏、Slash Command。 + +## 定义方式 + +```ts +import ExtensionFoo from "./tiptap/extension-foo.ts" + +export default definePlugin({ + extensionPoints: { + "default:editor:extension:create": () => { + return [ExtensionFoo]; + }, + }, +}); +``` + +其中,`ExtensionFoo` 是一个 Tiptap Extension,可以参考 [Tiptap 文档](https://tiptap.dev/) 和 [https://github.com/halo-sigs/richtext-editor/blob/main/docs/extension.md](https://github.com/halo-sigs/richtext-editor/blob/main/docs/extension.md)。 diff --git a/docs/extension-points/editor.md b/docs/extension-points/editor.md new file mode 100644 index 0000000..5e19f6a --- /dev/null +++ b/docs/extension-points/editor.md @@ -0,0 +1,74 @@ +# 编辑器集成扩展点 + +## 定义方式 + +```ts +import MarkdownEditor from "./components/MarkdownEditor.vue" + +export default definePlugin({ + extensionPoints: { + "editor:create": () => { + return [ + { + name: "markdown-editor", + displayName: "Markdown", + logo: "logo.png" + component: markRaw(MarkdownEditor), + rawType: "markdown", + }, + ]; + }, + }, +}); +``` + +- name: 编辑器名称,用于标识编辑器 +- displayName: 编辑器显示名称 +- component: 编辑器组件 +- rawType: 编辑器支持的原始类型,可以完全由插件定义。但必须保证最终能够将渲染后的 html 设置到 content 中。 + +## 组件 + +组件必须设置两个 `v-model` 绑定。即 `v-model:raw` 和 `v-model:content`,以下是示例: + +```vue +