初始化

This commit is contained in:
zhouhongshuo 2024-08-25 23:46:05 +08:00
commit ca9430f82c
1102 changed files with 156292 additions and 0 deletions

8
.changeset/README.md Normal file
View File

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

11
.changeset/config.json Normal file
View File

@ -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": []
}

9
.changeset/pre.json Normal file
View File

@ -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": []
}

12
.editorconfig Normal file
View File

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

0
.env.development Normal file
View File

0
.env.production Normal file
View File

31
.eslintrc.cjs Normal file
View File

@ -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",
},
};

32
.gitignore vendored Normal file
View File

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

15
.gitpod.yml Normal file
View File

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

4
.husky/pre-commit Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd ui && pnpm exec lint-staged

4
.npmignore Normal file
View File

@ -0,0 +1,4 @@
/node_modules/*
/.idea/*
/.git/*
/.github/*

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
strict-peer-dependencies=false
auto-install-peers=true

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
pnpm-lock.yaml
packages/api-client

8
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"vue.volar"
]
}

28
Makefile Normal file
View File

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

15
OWNERS Normal file
View File

@ -0,0 +1,15 @@
reviewers:
- ruibaby
- guqing
- JohnNiang
- lan-yonghui
- wan92hen
- QuentinHsu
- Aanko
- wzrove
- LIlGG
approvers:
- ruibaby
- guqing
- JohnNiang

49
README.md Normal file
View File

@ -0,0 +1,49 @@
## README
<p align="center">
<a href="https://halo.run" target="_blank" rel="noopener noreferrer">
<img width="100" src="https://halo.run/logo" alt="Halo logo" />
</a>
</p>
> Halo 2.0 的管理端项目(原 halo-admin
<p align="center">
<a href="https://github.com/halo-dev/console/releases"><img alt="GitHub release" src="https://img.shields.io/github/release/halo-dev/console.svg?style=flat-square" /></a>
<a href="https://github.com/halo-dev/console/blob/master/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/halo-dev/console?style=flat-square"></a>
<a href="https://github.com/halo-dev/console/commits"><img alt="GitHub last commit" src="https://img.shields.io/github/last-commit/halo-dev/console.svg?style=flat-square"></a>
<a href="https://github.com/halo-dev/console/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/halo-dev/console/main.yml?branch=main&style=flat-square"/></a>
<a href="https://gitpod.io/#https://github.com/halo-dev/console"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod&style=flat-square"/></a>
</p>
------------------------------
当前仓库已经将 `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")

74
build.gradle Normal file
View File

@ -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']
}

7
console-src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<script lang="ts" setup>
import BaseApp from "@/components/base-app/BaseApp.vue";
</script>
<template>
<BaseApp />
</template>

View File

@ -0,0 +1,33 @@
import { coreApiClient } from "@halo-dev/api-client";
import { nextTick, ref, watch, type Ref } from "vue";
interface SnapshotContent {
version: Ref<number>;
handleFetchSnapshot: () => Promise<void>;
}
export function useContentSnapshot(
snapshotName: Ref<string | undefined>
): 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,
};
}

View File

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

View File

@ -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<T>(
extensionPointName: string,
entity: Ref<T>,
presets: ComputedRef<EntityFieldItem[]>
) {
const { pluginModules } = usePluginModuleStore();
const itemsFromPlugins = ref<EntityFieldItem[]>([]);
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 };
}

View File

@ -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<GlobalInfo>({
queryKey: ["globalinfo"],
queryFn: async () => {
const { data } = await axios.get<GlobalInfo>(`/actuator/globalinfo`, {
withCredentials: true,
});
return data;
},
});
return {
globalInfo: data,
};
}

View File

@ -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<T>(
extensionPointName: string,
entity: Ref<T>,
presets: ComputedRef<OperationItem<T>[]>
) {
const { pluginModules } = usePluginModuleStore();
const itemsFromPlugins = ref<OperationItem<T>[]>([]);
onMounted(() => {
pluginModules.forEach((pluginModule: PluginModule) => {
const { extensionPoints } = pluginModule;
if (!extensionPoints?.[extensionPointName]) {
return;
}
const items = extensionPoints[extensionPointName](
entity
) as OperationItem<T>[];
itemsFromPlugins.value.push(...items);
});
});
const operationItems = computed(() => {
return [...presets.value, ...itemsFromPlugins.value].sort((a, b) => {
return a.priority - b.priority;
});
});
return { operationItems };
}

View File

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

View File

@ -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<Record<string, Record<string, string>>>;
convertToSave: () => ConfigMap | undefined;
}
export function useSettingFormConvert(
setting: Ref<Setting | undefined>,
configMap: Ref<ConfigMap | undefined>,
group: Ref<string>
): useSettingFormConvertReturn {
const configMapFormData = ref<Record<string, Record<string, string>>>({});
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,
};
}

View File

@ -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<string>,
target: Ref<string>,
auto: Ref<boolean>,
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,
};
}

View File

@ -0,0 +1,352 @@
<script lang="ts" setup>
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
import LoginModal from "@/components/login/LoginModal.vue";
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
import { rbacAnnotations } from "@/constants/annotations";
import { useUserStore } from "@/stores/user";
import { isMac } from "@/utils/device";
import { coreMenuGroups } from "@console/router/constant";
import {
Dialog,
IconAccountCircleLine,
IconLogoutCircleRLine,
IconMore,
IconSearch,
IconUserSettings,
VAvatar,
VTag,
} from "@halo-dev/components";
import { useEventListener } from "@vueuse/core";
import axios from "axios";
import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import { defineStore, storeToRefs } from "pinia";
import { onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { RouterView, useRoute, useRouter } from "vue-router";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const moreMenuVisible = ref(false);
const moreMenuRootVisible = ref(false);
const userStore = useUserStore();
const { currentRoles, currentUser } = storeToRefs(userStore);
const handleLogout = () => {
Dialog.warning({
title: t("core.sidebar.operations.logout.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await axios.post(`/logout`, undefined, {
withCredentials: true,
});
await userStore.fetchCurrentUser();
// Clear csrf token
document.cookie =
"XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
router.replace({ name: "Login" });
} catch (error) {
console.error("Failed to logout", error);
}
},
});
};
// Global Search
const globalSearchVisible = ref(false);
useEventListener(document, "keydown", (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = e;
if (key === "k" && ((ctrlKey && !isMac) || metaKey)) {
globalSearchVisible.value = true;
e.preventDefault();
}
});
const { menus, minimenus } = useRouteMenuGenerator(coreMenuGroups);
// aside scroll
const navbarScroller = ref();
const useNavbarScrollStore = defineStore("navbar", {
state: () => ({
y: 0,
}),
});
const navbarScrollStore = useNavbarScrollStore();
const reactiveParams = reactive<UseOverlayScrollbarsParams>({
options: {
scrollbars: {
autoHide: "scroll",
autoHideDelay: 600,
},
},
events: {
scroll: (_, onScrollArgs) => {
const target = onScrollArgs.target as HTMLElement;
navbarScrollStore.y = target.scrollTop;
},
updated: (instance) => {
const { viewport } = instance.elements();
if (!viewport) return;
viewport.scrollTo({ top: navbarScrollStore.y });
},
},
});
const [initialize] = useOverlayScrollbars(reactiveParams);
onMounted(() => {
if (navbarScroller.value) {
initialize({ target: navbarScroller.value });
}
});
</script>
<template>
<div class="flex min-h-screen">
<aside
class="navbar fixed hidden h-full overflow-y-auto md:flex md:flex-col"
>
<div class="logo flex justify-center pb-5 pt-5">
<a
href="/"
target="_blank"
:title="$t('core.sidebar.operations.visit_homepage.title')"
>
<IconLogo
class="cursor-pointer select-none transition-all hover:brightness-125"
/>
</a>
</div>
<div ref="navbarScroller" class="flex-1 overflow-y-hidden">
<div class="px-3">
<div
class="flex cursor-pointer items-center rounded bg-gray-100 p-2 text-gray-400 transition-all hover:text-gray-900"
@click="globalSearchVisible = true"
>
<span class="mr-3">
<IconSearch />
</span>
<span class="flex-1 select-none text-base font-normal">
{{ $t("core.sidebar.search.placeholder") }}
</span>
<div class="text-sm">
{{ `${isMac ? "⌘" : "Ctrl"}+K` }}
</div>
</div>
</div>
<RoutesMenu :menus="menus" />
</div>
<div class="profile-placeholder">
<div class="current-profile">
<div v-if="currentUser?.spec.avatar" class="profile-avatar">
<VAvatar
:src="currentUser?.spec.avatar"
:alt="currentUser?.spec.displayName"
size="sm"
circle
></VAvatar>
</div>
<div class="profile-name">
<div
class="flex text-sm font-medium"
:title="currentUser?.spec.displayName"
>
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.[0]" class="flex">
<VTag>
<template #leftIcon>
<IconUserSettings />
</template>
{{
currentRoles[0].metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || currentRoles[0].metadata.name
}}
</VTag>
</div>
</div>
<div class="flex items-center gap-1">
<a
v-tooltip="$t('core.sidebar.operations.profile.tooltip')"
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
href="/uc"
>
<IconAccountCircleLine
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</a>
<div
v-tooltip="$t('core.sidebar.operations.logout.tooltip')"
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
@click="handleLogout"
>
<IconLogoutCircleRLine
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</div>
</div>
</aside>
<main class="content w-full pb-12 mb-safe md:w-[calc(100%-16rem)] md:pb-0">
<slot v-if="$slots.default" />
<RouterView v-else />
<footer
v-if="!route.meta.hideFooter"
class="mt-auto p-4 text-center text-sm"
>
<span class="text-gray-600">Powered by </span>
<RouterLink to="/overview" class="hover:text-gray-600">
Halo
</RouterLink>
</footer>
</main>
<!--bottom nav bar-->
<div
v-if="minimenus"
class="bottom-nav-bar fixed bottom-0 left-0 right-0 grid grid-cols-6 border-t-2 border-black bg-secondary drop-shadow-2xl mt-safe pb-safe md:hidden"
>
<div
v-for="(menu, index) in minimenus"
:key="index"
:class="{ 'bg-black': route.path === menu?.path }"
class="nav-item"
@click="router.push(menu?.path)"
>
<div
class="flex w-full cursor-pointer items-center justify-center p-1 text-white"
>
<div class="flex h-10 w-10 flex-col items-center justify-center">
<div class="text-base">
<Component :is="menu?.icon" />
</div>
</div>
</div>
</div>
<div class="nav-item" @click="moreMenuVisible = true">
<div
class="flex w-full cursor-pointer items-center justify-center p-1 text-white"
>
<div class="flex h-10 w-10 flex-col items-center justify-center">
<div class="text-base">
<IconMore />
</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-show="moreMenuRootVisible"
class="drawer-wrapper fixed left-0 top-0 z-[99999] flex h-full w-full flex-row items-end justify-center"
>
<transition
enter-active-class="ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
@before-enter="moreMenuRootVisible = true"
@after-leave="moreMenuRootVisible = false"
>
<div
v-show="moreMenuVisible"
class="drawer-layer absolute left-0 top-0 h-full w-full flex-none bg-gray-500 bg-opacity-75 transition-opacity"
@click="moreMenuVisible = false"
></div>
</transition>
<transition
enter-active-class="transform transition ease-in-out duration-500 sm:duration-700"
enter-from-class="translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transform transition ease-in-out duration-500 sm:duration-700"
leave-from-class="translate-y-0"
leave-to-class="translate-y-full"
>
<div
v-show="moreMenuVisible"
class="drawer-content relative flex h-3/4 w-screen flex-col items-stretch overflow-y-auto rounded-t-md bg-white shadow-xl"
>
<div class="drawer-body">
<RoutesMenu
:menus="menus"
class="p-0"
@select="moreMenuVisible = false"
/>
</div>
</div>
</transition>
</div>
</Teleport>
</div>
</div>
<GlobalSearchModal
v-if="globalSearchVisible"
@close="globalSearchVisible = false"
/>
<LoginModal />
</template>
<style lang="scss">
.navbar {
@apply w-64;
@apply bg-white;
@apply shadow;
z-index: 999;
.profile-placeholder {
height: 70px;
.current-profile {
height: 70px;
@apply fixed
bottom-0
left-0
flex
w-64
gap-3
bg-white
p-3;
.profile-avatar {
@apply flex
items-center
self-center;
}
.profile-name {
@apply flex-1
self-center
overflow-hidden;
}
}
}
}
.content {
@apply ml-0
flex
flex-auto
flex-col
md:ml-64;
}
</style>

View File

@ -0,0 +1,7 @@
<template>
<RouterView />
</template>
<script lang="ts" setup>
import { RouterView } from "vue-router";
</script>

113
console-src/main.ts Normal file
View File

@ -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<string[]>) => {
const uiPermissions = Array.from<string>(
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");
}
}

View File

@ -0,0 +1,627 @@
<script lang="ts" setup>
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
import LazyImage from "@/components/image/LazyImage.vue";
import { isImage } from "@/utils/image";
import type { Attachment, Group } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
IconArrowLeft,
IconArrowRight,
IconCheckboxFill,
IconDatabase2Line,
IconFolder,
IconGrid,
IconList,
IconRefreshLine,
IconUpload,
Toast,
VButton,
VCard,
VDropdown,
VDropdownItem,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
} from "@halo-dev/components";
import { useLocalStorage } from "@vueuse/core";
import { useRouteQuery } from "@vueuse/router";
import type { Ref } from "vue";
import { computed, onMounted, provide, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import AttachmentDetailModal from "./components/AttachmentDetailModal.vue";
import AttachmentGroupList from "./components/AttachmentGroupList.vue";
import AttachmentListItem from "./components/AttachmentListItem.vue";
import AttachmentPoliciesModal from "./components/AttachmentPoliciesModal.vue";
import AttachmentUploadModal from "./components/AttachmentUploadModal.vue";
import AttachmentLoading from "./components/AttachmentLoading.vue";
import AttachmentError from "./components/AttachmentError.vue";
import { useAttachmentControl } from "./composables/use-attachment";
import { useFetchAttachmentGroup } from "./composables/use-attachment-group";
import { useFetchAttachmentPolicy } from "./composables/use-attachment-policy";
import LazyVideo from "@/components/video/LazyVideo.vue";
const { t } = useI18n();
const policyVisible = ref(false);
const uploadVisible = ref(false);
const detailVisible = ref(false);
const { policies } = useFetchAttachmentPolicy();
const { groups } = useFetchAttachmentGroup();
const selectedGroup = useRouteQuery<string | undefined>("group");
// Filter
const keyword = useRouteQuery<string>("keyword", "");
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 60, {
transform: Number,
});
const selectedPolicy = useRouteQuery<string | undefined>("policy");
const selectedUser = useRouteQuery<string | undefined>("user");
const selectedSort = useRouteQuery<string | undefined>("sort");
const selectedAccepts = useRouteQuery<string | undefined>("accepts");
watch(
() => [
selectedPolicy.value,
selectedUser.value,
selectedSort.value,
selectedAccepts.value,
keyword.value,
],
() => {
page.value = 1;
}
);
const hasFilters = computed(() => {
return (
selectedPolicy.value ||
selectedUser.value ||
selectedSort.value ||
selectedAccepts.value
);
});
function handleClearFilters() {
selectedPolicy.value = undefined;
selectedUser.value = undefined;
selectedSort.value = undefined;
selectedAccepts.value = undefined;
}
const {
attachments,
selectedAttachment,
selectedAttachments,
checkedAll,
isLoading,
isFetching,
total,
handleFetchAttachments,
handleSelectNext,
handleSelectPrevious,
handleDeleteInBatch,
handleCheckAll,
handleSelect,
isChecked,
handleReset,
} = useAttachmentControl({
groupName: selectedGroup,
policyName: selectedPolicy,
user: selectedUser,
accepts: computed(() => {
if (!selectedAccepts.value) {
return [];
}
return selectedAccepts.value.split(",");
}),
keyword: keyword,
sort: selectedSort,
page: page,
size: size,
});
provide<Ref<Set<Attachment>>>("selectedAttachments", selectedAttachments);
const handleMove = async (group: Group) => {
try {
const promises = Array.from(selectedAttachments.value).map((attachment) => {
return coreApiClient.storage.attachment.patchAttachment({
name: attachment.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/groupName",
value: group.metadata.name,
},
],
});
});
await Promise.all(promises);
selectedAttachments.value.clear();
Toast.success(t("core.attachment.operations.move.toast_success"));
} catch (e) {
console.error(e);
} finally {
handleFetchAttachments();
}
};
const handleClickItem = (attachment: Attachment) => {
if (attachment.metadata.deletionTimestamp) {
return;
}
if (selectedAttachments.value.size > 0) {
handleSelect(attachment);
return;
}
selectedAttachment.value = attachment;
selectedAttachments.value.clear();
detailVisible.value = true;
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
handleCheckAll(checked);
};
const onDetailModalClose = () => {
selectedAttachment.value = undefined;
nameQuery.value = undefined;
nameQueryAttachment.value = undefined;
detailVisible.value = false;
handleFetchAttachments();
};
const onUploadModalClose = () => {
routeQueryAction.value = undefined;
handleFetchAttachments();
uploadVisible.value = false;
};
// View type
const viewTypes = [
{
name: "list",
tooltip: t("core.attachment.filters.view_type.items.list"),
icon: IconList,
},
{
name: "grid",
tooltip: t("core.attachment.filters.view_type.items.grid"),
icon: IconGrid,
},
];
const viewType = useLocalStorage("attachment-view-type", "list");
// Route query action
const routeQueryAction = useRouteQuery<string | undefined>("action");
onMounted(() => {
if (!routeQueryAction.value) {
return;
}
if (routeQueryAction.value === "upload") {
uploadVisible.value = true;
}
});
const nameQuery = useRouteQuery<string | undefined>("name");
const nameQueryAttachment = ref<Attachment>();
watch(
() => selectedAttachment.value,
() => {
if (selectedAttachment.value) {
nameQuery.value = selectedAttachment.value.metadata.name;
}
}
);
onMounted(() => {
if (!nameQuery.value) {
return;
}
coreApiClient.storage.attachment
.getAttachment({
name: nameQuery.value,
})
.then((response) => {
nameQueryAttachment.value = response.data;
detailVisible.value = true;
});
});
</script>
<template>
<AttachmentDetailModal
v-if="detailVisible"
:attachment="selectedAttachment || nameQueryAttachment"
@close="onDetailModalClose"
>
<template #actions>
<span @click="handleSelectPrevious">
<IconArrowLeft />
</span>
<span @click="handleSelectNext">
<IconArrowRight />
</span>
</template>
</AttachmentDetailModal>
<AttachmentUploadModal v-if="uploadVisible" @close="onUploadModalClose" />
<AttachmentPoliciesModal
v-if="policyVisible"
@close="policyVisible = false"
/>
<VPageHeader :title="$t('core.attachment.title')">
<template #icon>
<IconFolder class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton
v-permission="['system:attachments:manage']"
size="sm"
@click="policyVisible = true"
>
<template #icon>
<IconDatabase2Line class="h-full w-full" />
</template>
{{ $t("core.attachment.actions.storage_policies") }}
</VButton>
<VButton
v-permission="['system:attachments:manage']"
type="secondary"
@click="uploadVisible = true"
>
<template #icon>
<IconUpload class="h-full w-full" />
</template>
{{ $t("core.common.buttons.upload") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<div class="flex flex-col gap-2 sm:flex-row">
<div class="w-full">
<VCard :body-class="[viewType === 'list' ? '!p-0' : '']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<div
v-permission="['system:attachments:manage']"
class="hidden items-center sm:flex"
>
<input
v-model="checkedAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput
v-if="!selectedAttachments.size"
v-model="keyword"
/>
<VSpace v-else>
<VButton type="danger" @click="handleDeleteInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
<VButton @click="selectedAttachments.clear()">
{{
$t("core.attachment.operations.deselect_items.button")
}}
</VButton>
<VDropdown v-if="groups?.length">
<VButton>
{{ $t("core.attachment.operations.move.button") }}
</VButton>
<template #popper>
<VDropdownItem
v-for="(group, index) in groups"
:key="index"
@click="handleMove(group)"
>
{{ group.spec.displayName }}
</VDropdownItem>
</template>
</VDropdown>
</VSpace>
</div>
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedPolicy"
:label="$t('core.attachment.filters.storage_policy.label')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
},
...(policies?.map((policy) => {
return {
label: policy.spec.displayName,
value: policy.metadata.name,
};
}) || []),
]"
/>
<FilterDropdown
v-model="selectedAccepts"
:label="$t('core.attachment.filters.accept.label')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
},
{
label: t('core.attachment.filters.accept.items.image'),
value: 'image/*',
},
{
label: t('core.attachment.filters.accept.items.audio'),
value: 'audio/*',
},
{
label: t('core.attachment.filters.accept.items.video'),
value: 'video/*',
},
{
label: t('core.attachment.filters.accept.items.file'),
value: 'text/*,application/*',
},
]"
/>
<HasPermission :permissions="['system:users:view']">
<UserFilterDropdown
v-model="selectedUser"
:label="$t('core.attachment.filters.owner.label')"
/>
</HasPermission>
<FilterDropdown
v-model="selectedSort"
:label="$t('core.common.filters.labels.sort')"
:items="[
{
label: t('core.common.filters.item_labels.default'),
},
{
label: t(
'core.attachment.filters.sort.items.create_time_desc'
),
value: 'metadata.creationTimestamp,desc',
},
{
label: t(
'core.attachment.filters.sort.items.create_time_asc'
),
value: 'metadata.creationTimestamp,asc',
},
{
label: t(
'core.attachment.filters.sort.items.display_name_desc'
),
value: 'spec.displayName,desc',
},
{
label: t(
'core.attachment.filters.sort.items.display_name_asc'
),
value: 'spec.displayName,asc',
},
{
label: t(
'core.attachment.filters.sort.items.size_desc'
),
value: 'spec.size,desc',
},
{
label: t('core.attachment.filters.sort.items.size_asc'),
value: 'spec.size,asc',
},
]"
/>
<div class="flex flex-row gap-2">
<div
v-for="(item, index) in viewTypes"
:key="index"
v-tooltip="`${item.tooltip}`"
:class="{
'bg-gray-200 font-bold text-black':
viewType === item.name,
}"
class="cursor-pointer rounded p-1 hover:bg-gray-200"
@click="viewType = item.name"
>
<component :is="item.icon" class="h-4 w-4" />
</div>
</div>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="handleFetchAttachments()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<div :style="`${viewType === 'list' ? 'padding:12px 16px 0' : ''}`">
<AttachmentGroupList @select="handleReset" />
</div>
<VLoading v-if="isLoading" />
<Transition v-else-if="!attachments?.length" appear name="fade">
<VEmpty
:message="$t('core.attachment.empty.message')"
:title="$t('core.attachment.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchAttachments">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:attachments:manage']"
type="secondary"
@click="uploadVisible = true"
>
<template #icon>
<IconUpload class="h-full w-full" />
</template>
{{ $t("core.attachment.empty.actions.upload") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<div v-else>
<Transition v-if="viewType === 'grid'" appear name="fade">
<div
class="mt-2 grid grid-cols-3 gap-x-2 gap-y-3 sm:grid-cols-3 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12"
role="list"
>
<VCard
v-for="attachment in attachments"
:key="attachment.metadata.name"
:body-class="['!p-0']"
:class="{
'ring-1 ring-primary': isChecked(attachment),
'ring-1 ring-red-600':
attachment.metadata.deletionTimestamp,
}"
class="hover:shadow"
@click="handleClickItem(attachment)"
>
<div class="group relative bg-white">
<div
class="aspect-h-8 aspect-w-10 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
>
<LazyImage
v-if="isImage(attachment.spec.mediaType)"
:key="attachment.metadata.name"
:alt="attachment.spec.displayName"
:src="attachment.status?.permalink"
classes="pointer-events-none object-cover group-hover:opacity-75 transform-gpu"
>
<template #loading>
<AttachmentLoading />
</template>
<template #error>
<AttachmentError />
</template>
</LazyImage>
<LazyVideo
v-else-if="
attachment?.spec.mediaType?.startsWith('video/')
"
:src="attachment.status?.permalink"
classes="object-cover group-hover:opacity-75"
>
<template #loading>
<AttachmentLoading />
</template>
<template #error>
<AttachmentError />
</template>
</LazyVideo>
<AttachmentFileTypeIcon
v-else
:file-name="attachment.spec.displayName"
/>
</div>
<p
v-tooltip="attachment.spec.displayName"
class="block cursor-pointer truncate px-2 py-1 text-center text-xs font-medium text-gray-700"
>
{{ attachment.spec.displayName }}
</p>
<div
v-if="attachment.metadata.deletionTimestamp"
class="absolute right-1 top-1 text-xs text-red-300"
>
{{ $t("core.common.status.deleting") }}...
</div>
<div
v-if="!attachment.metadata.deletionTimestamp"
v-permission="['system:attachments:manage']"
:class="{ '!flex': selectedAttachments.has(attachment) }"
class="absolute left-0 top-0 hidden h-1/3 w-full cursor-pointer justify-end bg-gradient-to-b from-gray-300 to-transparent ease-in-out group-hover:flex"
>
<IconCheckboxFill
:class="{
'!text-primary': selectedAttachments.has(attachment),
}"
class="mr-1 mt-1 h-6 w-6 cursor-pointer text-white transition-all hover:text-primary"
@click.stop="handleSelect(attachment)"
/>
</div>
</div>
</VCard>
</div>
</Transition>
<Transition v-if="viewType === 'list'" appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li
v-for="attachment in attachments"
:key="attachment.metadata.name"
>
<AttachmentListItem
:attachment="attachment"
:is-selected="isChecked(attachment)"
@select="handleSelect"
@open-detail="handleClickItem"
/>
</li>
</ul>
</Transition>
</div>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[60, 120, 200]"
/>
</template>
</VCard>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,192 @@
<script lang="ts" setup>
import LazyImage from "@/components/image/LazyImage.vue";
import { formatDatetime } from "@/utils/date";
import { isImage } from "@/utils/image";
import type { Attachment } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
VButton,
VDescription,
VDescriptionItem,
VModal,
VSpace,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import prettyBytes from "pretty-bytes";
import { computed, ref } from "vue";
import { useFetchAttachmentGroup } from "../composables/use-attachment-group";
import AttachmentPermalinkList from "./AttachmentPermalinkList.vue";
const props = withDefaults(
defineProps<{
attachment: Attachment | undefined;
mountToBody?: boolean;
}>(),
{
attachment: undefined,
mountToBody: false,
}
);
const emit = defineEmits<{
(event: "close"): void;
}>();
const { groups } = useFetchAttachmentGroup();
const onlyPreview = ref(false);
const policyName = computed(() => {
return props.attachment?.spec.policyName;
});
const { data: policy } = useQuery({
queryKey: ["attachment-policy", policyName],
queryFn: async () => {
if (!policyName.value) {
return;
}
const { data } = await coreApiClient.storage.policy.getPolicy({
name: policyName.value,
});
return data;
},
enabled: computed(() => !!policyName.value),
});
const getGroupName = (name: string | undefined) => {
const group = groups.value?.find((group) => group.metadata.name === name);
return group?.spec.displayName || name;
};
</script>
<template>
<VModal
:title="
$t('core.attachment.detail_modal.title', {
display_name: attachment?.spec.displayName || '',
})
"
:width="1000"
:mount-to-body="mountToBody"
:layer-closable="true"
height="calc(100vh - 20px)"
:body-class="['!p-0']"
@close="emit('close')"
>
<template #actions>
<slot name="actions"></slot>
</template>
<div class="overflow-hidden bg-white">
<div
v-if="onlyPreview && isImage(attachment?.spec.mediaType)"
class="flex justify-center p-4"
>
<img
v-tooltip.bottom="
$t('core.attachment.detail_modal.preview.click_to_exit')
"
:alt="attachment?.spec.displayName"
:src="attachment?.status?.permalink"
class="w-auto transform-gpu cursor-pointer rounded"
@click="onlyPreview = !onlyPreview"
/>
</div>
<div v-else>
<VDescription>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.preview')"
>
<div
v-if="isImage(attachment?.spec.mediaType)"
@click="onlyPreview = !onlyPreview"
>
<LazyImage
:alt="attachment?.spec.displayName"
:src="attachment?.status?.permalink"
classes="max-w-full cursor-pointer rounded sm:max-w-[50%]"
>
<template #loading>
<span class="text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</template>
<template #error>
<span class="text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</template>
</LazyImage>
</div>
<div v-else-if="attachment?.spec.mediaType?.startsWith('video/')">
<video
:src="attachment.status?.permalink"
controls
class="max-w-full rounded sm:max-w-[50%]"
>
{{
$t("core.attachment.detail_modal.preview.video_not_support")
}}
</video>
</div>
<div v-else-if="attachment?.spec.mediaType?.startsWith('audio/')">
<audio :src="attachment.status?.permalink" controls>
{{
$t("core.attachment.detail_modal.preview.audio_not_support")
}}
</audio>
</div>
<span v-else>
{{ $t("core.attachment.detail_modal.preview.not_support") }}
</span>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.storage_policy')"
:content="policy?.spec.displayName"
></VDescriptionItem>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.group')"
:content="
getGroupName(attachment?.spec.groupName) ||
$t('core.attachment.common.text.ungrouped')
"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.display_name')"
:content="attachment?.spec.displayName"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.media_type')"
:content="attachment?.spec.mediaType"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.size')"
:content="prettyBytes(attachment?.spec.size || 0)"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.owner')"
:content="attachment?.spec.ownerName"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.creation_time')"
:content="formatDatetime(attachment?.metadata.creationTimestamp)"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.permalink')"
>
<AttachmentPermalinkList :attachment="attachment" />
</VDescriptionItem>
</VDescription>
</div>
</div>
<template #footer>
<VSpace>
<VButton type="default" @click="emit('close')">
{{ $t("core.common.buttons.close_and_shortcut") }}
</VButton>
<slot name="footer" />
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,7 @@
<template>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>

View File

@ -0,0 +1,216 @@
<script lang="ts" setup>
import type { Group } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconCheckboxCircle,
IconMore,
Toast,
VDropdown,
VDropdownItem,
VStatusDot,
} from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import AttachmentGroupEditingModal from "./AttachmentGroupEditingModal.vue";
const props = withDefaults(
defineProps<{
group?: Group;
isSelected?: boolean;
features?: { actions: boolean; checkIcon?: boolean };
}>(),
{
group: undefined,
isSelected: false,
features: () => {
return {
actions: true,
checkIcon: false,
};
},
}
);
const { t } = useI18n();
const queryClient = useQueryClient();
const handleDelete = () => {
Dialog.warning({
title: t("core.attachment.group_list.operations.delete.title"),
description: t("core.attachment.group_list.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
if (!props.group) {
return;
}
// TODO:
const { data } =
await consoleApiClient.storage.attachment.searchAttachments({
fieldSelector: [`spec.groupName=${props.group.metadata.name}`],
page: 0,
size: 0,
});
await coreApiClient.storage.group.deleteGroup({
name: props.group.metadata.name,
});
// move attachments to none group
const moveToUnGroupRequests = data.items.map((attachment) => {
return coreApiClient.storage.attachment.patchAttachment({
name: attachment.metadata.name,
jsonPatchInner: [
{
op: "remove",
path: "/spec/groupName",
},
],
});
});
await Promise.all(moveToUnGroupRequests);
queryClient.invalidateQueries({ queryKey: ["attachment-groups"] });
queryClient.invalidateQueries({ queryKey: ["attachments"] });
Toast.success(
t("core.attachment.group_list.operations.delete.toast_success", {
total: data.total,
})
);
},
});
};
const handleDeleteWithAttachments = () => {
Dialog.warning({
title: t(
"core.attachment.group_list.operations.delete_with_attachments.title"
),
description: t(
"core.attachment.group_list.operations.delete_with_attachments.description"
),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
if (!props.group) {
return;
}
// TODO:
const { data } =
await consoleApiClient.storage.attachment.searchAttachments({
fieldSelector: [`spec.groupName=${props.group.metadata.name}`],
page: 0,
size: 0,
});
await coreApiClient.storage.group.deleteGroup({
name: props.group.metadata.name,
});
const deleteAttachmentRequests = data.items.map((attachment) => {
return coreApiClient.storage.attachment.deleteAttachment({
name: attachment.metadata.name,
});
});
await Promise.all(deleteAttachmentRequests);
queryClient.invalidateQueries({ queryKey: ["attachment-groups"] });
queryClient.invalidateQueries({ queryKey: ["attachments"] });
Toast.success(
t(
"core.attachment.group_list.operations.delete_with_attachments.toast_success",
{ total: data.total }
)
);
},
});
};
// Editing
const editingModalVisible = ref(false);
const onEditingModalClose = () => {
queryClient.invalidateQueries({ queryKey: ["attachment-groups"] });
editingModalVisible.value = false;
};
</script>
<template>
<button
type="button"
class="inline-flex h-full w-full items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-2.5 text-sm font-medium text-gray-800 hover:bg-gray-50 hover:shadow-sm"
:class="{ '!bg-gray-100 shadow-sm': isSelected }"
>
<div class="inline-flex w-full flex-1 gap-x-2 break-all text-left">
<slot name="text">
{{ group?.spec.displayName }}
</slot>
<VStatusDot
v-if="group?.metadata.deletionTimestamp"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</div>
<div class="flex-none">
<HasPermission
v-if="features.actions"
:permissions="['system:attachments:manage']"
>
<VDropdown>
<IconMore @click.stop />
<template #popper>
<VDropdownItem @click="editingModalVisible = true">
{{ $t("core.attachment.group_list.operations.rename.button") }}
</VDropdownItem>
<VDropdown placement="right" :triggers="['click']">
<VDropdownItem type="danger">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
<template #popper>
<VDropdownItem type="danger" @click="handleDelete()">
{{
$t("core.attachment.group_list.operations.delete.button")
}}
</VDropdownItem>
<VDropdownItem
type="danger"
@click="handleDeleteWithAttachments()"
>
{{
$t(
"core.attachment.group_list.operations.delete_with_attachments.button"
)
}}
</VDropdownItem>
</template>
</VDropdown>
</template>
</VDropdown>
</HasPermission>
<IconCheckboxCircle
v-if="isSelected && features.checkIcon"
class="text-primary"
/>
<slot name="actions" />
</div>
<AttachmentGroupEditingModal
v-if="editingModalVisible"
:group="group"
@close="onEditingModalClose"
/>
</button>
</template>

View File

@ -0,0 +1,117 @@
<script lang="ts" setup>
import SubmitButton from "@/components/button/SubmitButton.vue";
import { setFocus } from "@/formkit/utils/focus";
import type { Group } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import { cloneDeep } from "lodash-es";
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
group?: Group;
}>(),
{
group: undefined,
}
);
const emit = defineEmits<{
(event: "close"): void;
}>();
const { t } = useI18n();
const modal = ref<InstanceType<typeof VModal> | null>(null);
const formState = ref<Group>({
spec: {
displayName: "",
},
apiVersion: "storage.halo.run/v1alpha1",
kind: "Group",
metadata: {
name: "",
generateName: "attachment-group-",
},
});
const isSubmitting = ref(false);
const modalTitle = props.group
? t("core.attachment.group_editing_modal.titles.update")
: t("core.attachment.group_editing_modal.titles.create");
const handleSave = async () => {
try {
isSubmitting.value = true;
if (props.group) {
await coreApiClient.storage.group.updateGroup({
name: formState.value.metadata.name,
group: formState.value,
});
} else {
await coreApiClient.storage.group.createGroup({
group: formState.value,
});
}
Toast.success(t("core.common.toast.save_success"));
modal.value?.close();
} catch (e) {
console.error("Failed to save attachment group", e);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
setFocus("displayNameInput");
if (props.group) {
formState.value = cloneDeep(props.group);
}
});
</script>
<template>
<VModal
ref="modal"
mount-to-body
:title="modalTitle"
:width="500"
@close="emit('close')"
>
<FormKit
id="attachment-group-form"
name="attachment-group-form"
:config="{ validationVisibility: 'submit' }"
type="form"
:actions="false"
@submit="handleSave"
>
<FormKit
id="displayNameInput"
v-model="formState.spec.displayName"
:label="
$t('core.attachment.group_editing_modal.fields.display_name.label')
"
type="text"
name="displayName"
validation="required|length:0,50"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<SubmitButton
:loading="isSubmitting"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('attachment-group-form')"
>
</SubmitButton>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,114 @@
<script lang="ts" setup>
import type { Group } from "@halo-dev/api-client";
import { IconAddCircle } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { useFetchAttachmentGroup } from "../composables/use-attachment-group";
import AttachmentGroupBadge from "./AttachmentGroupBadge.vue";
import AttachmentGroupEditingModal from "./AttachmentGroupEditingModal.vue";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
readonly?: boolean;
}>(),
{
readonly: false,
}
);
const emit = defineEmits<{
(event: "select", group: Group): void;
}>();
const queryClient = useQueryClient();
const defaultGroups: Group[] = [
{
spec: {
displayName: t("core.attachment.group_list.internal_groups.all"),
},
apiVersion: "",
kind: "",
metadata: {
name: "",
},
},
{
spec: {
displayName: t("core.attachment.common.text.ungrouped"),
},
apiVersion: "",
kind: "",
metadata: {
name: "ungrouped",
},
},
];
const { groups } = useFetchAttachmentGroup();
const loading = ref<boolean>(false);
const creationModalVisible = ref(false);
const selectedGroup = props.readonly
? ref("")
: useRouteQuery<string>("group", "");
const handleSelectGroup = (group: Group) => {
emit("select", group);
selectedGroup.value = group.metadata.name;
};
const onCreationModalClose = () => {
queryClient.invalidateQueries({ queryKey: ["attachment-groups"] });
creationModalVisible.value = false;
};
</script>
<template>
<AttachmentGroupEditingModal
v-if="!readonly && creationModalVisible"
@close="onCreationModalClose"
/>
<div
class="mb-5 grid grid-cols-2 gap-x-2 gap-y-3 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-6"
>
<AttachmentGroupBadge
v-for="defaultGroup in defaultGroups"
:key="defaultGroup.metadata.name"
:group="defaultGroup"
:is-selected="defaultGroup.metadata.name === selectedGroup"
:features="{ actions: false, checkIcon: readonly }"
@click="handleSelectGroup(defaultGroup)"
/>
<AttachmentGroupBadge
v-for="group in groups"
:key="group.metadata.name"
:group="group"
:is-selected="group.metadata.name === selectedGroup"
:features="{ actions: !readonly, checkIcon: readonly }"
@click="handleSelectGroup(group)"
/>
<HasPermission
v-if="!loading && !readonly"
:permissions="['system:attachments:manage']"
>
<AttachmentGroupBadge
:features="{ actions: false }"
@click="creationModalVisible = true"
>
<template #text>
<span>{{ $t("core.common.buttons.new") }}</span>
</template>
<template #actions>
<IconAddCircle />
</template>
</AttachmentGroupBadge>
</HasPermission>
</div>
</template>

View File

@ -0,0 +1,223 @@
<script lang="ts" setup>
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import { useOperationItemExtensionPoint } from "@console/composables/use-operation-extension-points";
import type { Attachment } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
Toast,
VDropdownDivider,
VDropdownItem,
VEntity,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import type { OperationItem } from "@halo-dev/console-shared";
import { useQueryClient } from "@tanstack/vue-query";
import prettyBytes from "pretty-bytes";
import type { Ref } from "vue";
import { computed, inject, markRaw, ref, toRefs } from "vue";
import { useI18n } from "vue-i18n";
import { useFetchAttachmentPolicy } from "../composables/use-attachment-policy";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
attachment: Attachment;
isSelected?: boolean;
}>(),
{ isSelected: false }
);
const { attachment } = toRefs(props);
const { policies } = useFetchAttachmentPolicy();
const emit = defineEmits<{
(event: "select", attachment?: Attachment): void;
(event: "open-detail", attachment: Attachment): void;
}>();
const selectedAttachments = inject<Ref<Set<Attachment>>>(
"selectedAttachments",
ref<Set<Attachment>>(new Set())
);
const policyDisplayName = computed(() => {
const policy = policies.value?.find(
(p) => p.metadata.name === props.attachment.spec.policyName
);
return policy?.spec.displayName;
});
const handleDelete = () => {
Dialog.warning({
title: t("core.attachment.operations.delete.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 coreApiClient.storage.attachment.deleteAttachment({
name: props.attachment.metadata.name,
});
selectedAttachments.value.delete(props.attachment);
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete attachment", e);
} finally {
queryClient.invalidateQueries({ queryKey: ["attachments"] });
}
},
});
};
const { operationItems } = useOperationItemExtensionPoint<Attachment>(
"attachment:list-item:operation:create",
attachment,
computed((): OperationItem<Attachment>[] => [
{
priority: 10,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.detail"),
permissions: [],
action: () => {
emit("open-detail", attachment.value);
},
},
{
priority: 20,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.download"),
action: () => {
const { permalink } = attachment.value.status || {};
if (!permalink) {
throw new Error("Attachment has no permalink");
}
const a = document.createElement("a");
a.href = permalink;
a.download = attachment.value.spec.displayName || "unknown";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
},
{
priority: 30,
component: markRaw(VDropdownDivider),
},
{
priority: 40,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.delete"),
permissions: ["system:attachments:manage"],
action: () => {
handleDelete();
},
},
])
);
</script>
<template>
<VEntity :is-selected="isSelected">
<template
v-if="currentUserHasPermission(['system:attachments:manage'])"
#checkbox
>
<input
:checked="selectedAttachments.has(attachment)"
type="checkbox"
@click="emit('select', attachment)"
/>
</template>
<template #start>
<VEntityField>
<template #description>
<div class="h-10 w-10 rounded border bg-white p-1 hover:shadow-sm">
<AttachmentFileTypeIcon
:display-ext="false"
:file-name="attachment.spec.displayName"
:width="8"
:height="8"
/>
</div>
</template>
</VEntityField>
<VEntityField
:title="attachment.spec.displayName"
@click="emit('open-detail', attachment)"
>
<template #description>
<VSpace>
<span class="text-xs text-gray-500">
{{ attachment.spec.mediaType }}
</span>
<span class="text-xs text-gray-500">
{{ prettyBytes(attachment.spec.size || 0) }}
</span>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField :description="policyDisplayName" />
<VEntityField>
<template #description>
<RouterLink
:to="{
name: 'UserDetail',
params: {
name: attachment.spec.ownerName,
},
}"
class="text-xs text-gray-500"
:class="{
'pointer-events-none': !currentUserHasPermission([
'system:users:view',
]),
}"
>
{{ attachment.spec.ownerName }}
</RouterLink>
</template>
</VEntityField>
<VEntityField v-if="attachment.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(attachment.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template #dropdownItems>
<EntityDropdownItems
:dropdown-items="operationItems"
:item="attachment"
/>
</template>
</VEntity>
</template>

View File

@ -0,0 +1,7 @@
<template>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>

View File

@ -0,0 +1,72 @@
<script lang="ts" setup>
import type { Attachment } from "@halo-dev/api-client";
import { VButton } from "@halo-dev/components";
import { computed, toRefs } from "vue";
import { useAttachmentPermalinkCopy } from "../composables/use-attachment";
const props = withDefaults(
defineProps<{
attachment?: Attachment;
mountToBody?: boolean;
}>(),
{
attachment: undefined,
mountToBody: false,
}
);
const { attachment } = toRefs(props);
const { handleCopy, htmlText, markdownText } =
useAttachmentPermalinkCopy(attachment);
const formats = computed(
(): {
label: string;
key: "url" | "html" | "markdown";
value?: string;
}[] => {
return [
{
label: "URL",
key: "url",
value: attachment?.value?.status?.permalink,
},
{
label: "HTML",
key: "html",
value: htmlText.value,
},
{
label: "Markdown",
key: "markdown",
value: markdownText.value,
},
];
}
);
</script>
<template>
<ul class="flex flex-col space-y-2">
<li v-for="format in formats" :key="format.key">
<div
class="flex w-full cursor-pointer items-center justify-between space-x-3 rounded border p-3 hover:border-primary"
>
<div class="flex flex-1 flex-col space-y-2 text-xs text-gray-900">
<span class="font-semibold">
{{ format.label }}
</span>
<span class="break-all">
{{ format.value }}
</span>
</div>
<div>
<VButton size="sm" @click="handleCopy(format.key)">
{{ $t("core.common.buttons.copy") }}
</VButton>
</div>
</div>
</li>
</ul>
</template>

View File

@ -0,0 +1,215 @@
<script lang="ts" setup>
import { formatDatetime } from "@/utils/date";
import type { Policy, PolicyTemplate } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconAddCircle,
Toast,
VButton,
VDropdown,
VDropdownItem,
VEmpty,
VEntity,
VEntityField,
VModal,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import {
useFetchAttachmentPolicy,
useFetchAttachmentPolicyTemplate,
} from "../composables/use-attachment-policy";
import AttachmentPolicyEditingModal from "./AttachmentPolicyEditingModal.vue";
const emit = defineEmits<{
(event: "close"): void;
}>();
const { t } = useI18n();
const { policies, isLoading, handleFetchPolicies } = useFetchAttachmentPolicy();
const { policyTemplates } = useFetchAttachmentPolicyTemplate();
const modal = ref<InstanceType<typeof VModal> | null>(null);
const selectedPolicy = ref<Policy>();
const selectedTemplateName = ref();
const policyEditingModal = ref(false);
const handleOpenEditingModal = (policy: Policy) => {
selectedPolicy.value = policy;
policyEditingModal.value = true;
};
const handleOpenCreateNewPolicyModal = (policyTemplate: PolicyTemplate) => {
selectedTemplateName.value = policyTemplate.metadata.name;
policyEditingModal.value = true;
};
const handleDelete = async (policy: Policy) => {
const { data } = await consoleApiClient.storage.attachment.searchAttachments({
fieldSelector: [`spec.policyName=${policy.metadata.name}`],
});
if (data.total > 0) {
Dialog.warning({
title: t(
"core.attachment.policies_modal.operations.can_not_delete.title"
),
description: t(
"core.attachment.policies_modal.operations.can_not_delete.description"
),
confirmText: t("core.common.buttons.confirm"),
showCancel: false,
});
return;
}
Dialog.warning({
title: t("core.attachment.policies_modal.operations.delete.title"),
description: t(
"core.attachment.policies_modal.operations.delete.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await coreApiClient.storage.policy.deletePolicy({
name: policy.metadata.name,
});
Toast.success(t("core.common.toast.delete_success"));
handleFetchPolicies();
},
});
};
const onEditingModalClose = () => {
selectedPolicy.value = undefined;
selectedTemplateName.value = undefined;
handleFetchPolicies();
policyEditingModal.value = false;
};
function getPolicyTemplateDisplayName(templateName: string) {
const policyTemplate = policyTemplates.value?.find(
(template) => template.metadata.name === templateName
);
return policyTemplate?.spec?.displayName || "--";
}
</script>
<template>
<VModal
ref="modal"
:width="750"
:title="$t('core.attachment.policies_modal.title')"
:body-class="['!p-0']"
:layer-closable="true"
@close="emit('close')"
>
<template #actions>
<VDropdown>
<span v-tooltip="$t('core.common.buttons.new')">
<IconAddCircle />
</span>
<template #popper>
<VDropdownItem
v-for="policyTemplate in policyTemplates"
:key="policyTemplate.metadata.name"
@click="handleOpenCreateNewPolicyModal(policyTemplate)"
>
{{ policyTemplate.spec?.displayName }}
</VDropdownItem>
</template>
</VDropdown>
</template>
<VEmpty
v-if="!policies?.length && !isLoading"
:message="$t('core.attachment.policies_modal.empty.message')"
:title="$t('core.attachment.policies_modal.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchPolicies">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VDropdown>
<VButton type="secondary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
<template #popper>
<VDropdownItem
v-for="(policyTemplate, index) in policyTemplates"
:key="index"
@click="handleOpenCreateNewPolicyModal(policyTemplate)"
>
{{ policyTemplate.spec?.displayName }}
</VDropdownItem>
</template>
</VDropdown>
</VSpace>
</template>
</VEmpty>
<ul
v-else
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(policy, index) in policies" :key="index">
<VEntity>
<template #start>
<VEntityField
:title="policy.spec.displayName"
:description="
getPolicyTemplateDisplayName(policy.spec.templateName)
"
></VEntityField>
</template>
<template #end>
<VEntityField v-if="policy.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(policy.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template #dropdownItems>
<VDropdownItem @click="handleOpenEditingModal(policy)">
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem type="danger" @click="handleDelete(policy)">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
</li>
</ul>
<template #footer>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.close_and_shortcut") }}
</VButton>
</template>
</VModal>
<AttachmentPolicyEditingModal
v-if="policyEditingModal"
:policy="selectedPolicy"
:template-name="selectedTemplateName"
@close="onEditingModalClose"
/>
</template>

View File

@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { Policy } from "@halo-dev/api-client";
import { IconCheckboxCircle } from "@halo-dev/components";
import { computed } from "vue";
import { useFetchAttachmentPolicyTemplate } from "../composables/use-attachment-policy";
const props = withDefaults(
defineProps<{
policy?: Policy;
isSelected?: boolean;
features?: { checkIcon?: boolean };
}>(),
{
policy: undefined,
isSelected: false,
features: () => {
return {
checkIcon: false,
};
},
}
);
const { policyTemplates } = useFetchAttachmentPolicyTemplate();
const policyTemplate = computed(() => {
return policyTemplates.value?.find(
(template) => template.metadata.name === props.policy?.spec.templateName
);
});
</script>
<template>
<button
type="button"
class="inline-flex h-full w-full items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-2.5 text-sm font-medium text-gray-800 hover:bg-gray-50 hover:shadow-sm"
:class="{ '!bg-gray-100 shadow-sm': isSelected }"
>
<div class="inline-flex w-full flex-1 flex-col space-y-0.5 text-left">
<slot name="text">
<div>
{{ policy?.spec.displayName }}
</div>
<div class="text-xs font-normal text-gray-600">
{{ policyTemplate?.spec?.displayName || "--" }}
</div>
</slot>
</div>
<div class="flex-none">
<IconCheckboxCircle
v-if="isSelected && features.checkIcon"
class="text-primary"
/>
<slot name="actions" />
</div>
</button>
</template>

View File

@ -0,0 +1,238 @@
<script lang="ts" setup>
import SubmitButton from "@/components/button/SubmitButton.vue";
import { setFocus } from "@/formkit/utils/focus";
import { useSettingFormConvert } from "@console/composables/use-setting-form";
import type { Policy } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import { Toast, VButton, VLoading, VModal, VSpace } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { cloneDeep } from "lodash-es";
import { computed, onMounted, ref, toRaw, toRefs } from "vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
policy?: Policy;
templateName?: string;
}>(),
{
policy: undefined,
templateName: undefined,
}
);
const { policy } = toRefs(props);
const emit = defineEmits<{
(event: "close"): void;
}>();
const { t } = useI18n();
const modal = ref<InstanceType<typeof VModal> | null>(null);
const formState = ref<Policy>({
spec: {
displayName: "",
templateName: "",
configMapName: "",
},
apiVersion: "storage.halo.run/v1alpha1",
kind: "Policy",
metadata: {
name: "",
generateName: "attachment-policy-",
},
});
const isUpdateMode = !!props.policy;
onMounted(async () => {
if (props.policy) {
formState.value = cloneDeep(props.policy);
}
if (props.templateName) {
formState.value.spec.templateName = props.templateName;
}
setFocus("displayNameInput");
});
const { data: policyTemplate } = useQuery({
queryKey: [
"core:attachment:policy-template",
formState.value.spec.templateName,
],
cacheTime: 0,
queryFn: async () => {
const { data } =
await coreApiClient.storage.policyTemplate.getPolicyTemplate({
name: formState.value.spec.templateName,
});
return data;
},
retry: 0,
enabled: computed(() => !!formState.value.spec.templateName),
});
const { data: setting, isLoading } = useQuery({
queryKey: [
"core:attachment:policy-template:setting",
policyTemplate.value?.spec?.settingName,
],
cacheTime: 0,
queryFn: async () => {
if (!policyTemplate.value?.spec?.settingName) {
throw new Error("No setting found");
}
const { data } = await coreApiClient.setting.getSetting({
name: policyTemplate.value.spec.settingName,
});
return data;
},
retry: 0,
enabled: computed(() => !!policyTemplate.value?.spec?.settingName),
});
const { data: configMap } = useQuery({
queryKey: [
"core:attachment:policy-template:configMap",
policy.value?.spec.configMapName,
],
cacheTime: 0,
initialData: {
data: {},
apiVersion: "v1alpha1",
kind: "ConfigMap",
metadata: {
generateName: "configMap-",
name: "",
},
},
retry: 0,
queryFn: async () => {
if (!policy.value?.spec.configMapName) {
throw new Error("No configMap found");
}
const { data } = await coreApiClient.configMap.getConfigMap({
name: policy.value?.spec.configMapName,
});
return data;
},
enabled: computed(() => !!policy.value?.spec.configMapName),
});
const { configMapFormData, formSchema, convertToSave } = useSettingFormConvert(
setting,
configMap,
ref("default")
);
const submitting = ref(false);
const handleSave = async () => {
try {
submitting.value = true;
const configMapToUpdate = convertToSave();
if (isUpdateMode) {
await coreApiClient.configMap.updateConfigMap({
name: configMap.value.metadata.name,
configMap: configMapToUpdate,
});
await coreApiClient.storage.policy.updatePolicy({
name: formState.value.metadata.name,
policy: formState.value,
});
} else {
const { data: newConfigMap } =
await coreApiClient.configMap.createConfigMap({
configMap: configMapToUpdate,
});
formState.value.spec.configMapName = newConfigMap.metadata.name;
await coreApiClient.storage.policy.createPolicy({
policy: formState.value,
});
}
Toast.success(t("core.common.toast.save_success"));
modal.value?.close();
} catch (e) {
console.error("Failed to save attachment policy", e);
} finally {
submitting.value = false;
}
};
const modalTitle = props.policy
? t("core.attachment.policy_editing_modal.titles.update", {
policy: props.policy?.spec.displayName,
})
: t("core.attachment.policy_editing_modal.titles.create", {
policy_template: policyTemplate.value?.spec?.displayName,
});
</script>
<template>
<VModal
ref="modal"
mount-to-body
:title="modalTitle"
:width="600"
@close="emit('close')"
>
<div>
<VLoading v-if="isLoading" />
<template v-else>
<FormKit
v-if="formSchema && configMapFormData"
id="attachment-policy-form"
v-model="configMapFormData['default']"
name="attachment-policy-form"
:actions="false"
:preserve="true"
type="form"
:config="{ validationVisibility: 'submit' }"
@submit="handleSave"
>
<FormKit
id="displayNameInput"
v-model="formState.spec.displayName"
:label="
$t(
'core.attachment.policy_editing_modal.fields.display_name.label'
)
"
type="text"
name="displayName"
validation="required|length:0,50"
></FormKit>
<FormKitSchema
:schema="toRaw(formSchema)"
:data="configMapFormData['default']"
/>
</FormKit>
</template>
</div>
<template #footer>
<VSpace>
<SubmitButton
:loading="submitting"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('attachment-policy-form')"
>
</SubmitButton>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,171 @@
<script lang="ts" setup>
import { usePluginModuleStore } from "@/stores/plugin";
import { VButton, VModal, VSpace, VTabbar } from "@halo-dev/components";
import type {
AttachmentLike,
AttachmentSelectProvider,
} from "@halo-dev/console-shared";
import { computed, markRaw, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import CoreSelectorProvider from "./selector-providers/CoreSelectorProvider.vue";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
visible: boolean;
accepts?: string[];
min?: number;
max?: number;
}>(),
{
visible: false,
accepts: () => ["*/*"],
min: undefined,
max: undefined,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
(event: "select", attachments: AttachmentLike[]): void;
}>();
const selected = ref<AttachmentLike[]>([] as AttachmentLike[]);
const attachmentSelectProviders = ref<AttachmentSelectProvider[]>([
{
id: "core",
label: t("core.attachment.select_modal.providers.default.label"),
component: markRaw(CoreSelectorProvider),
},
]);
// resolve plugin extension points
const { pluginModules } = usePluginModuleStore();
onMounted(async () => {
for (const pluginModule of pluginModules) {
try {
const callbackFunction =
pluginModule?.extensionPoints?.["attachment:selector:create"];
if (typeof callbackFunction !== "function") {
continue;
}
const providers = await callbackFunction();
attachmentSelectProviders.value.push(...providers);
} catch (error) {
console.error(`Error processing plugin module:`, pluginModule, error);
}
}
});
const activeId = ref(attachmentSelectProviders.value[0].id);
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const onChangeProvider = (providerId: string) => {
const provider = attachmentSelectProviders.value.find(
(provider) => provider.id === providerId
);
if (!provider) {
return;
}
activeId.value = providerId;
};
const handleConfirm = () => {
emit("select", Array.from(selected.value));
onVisibleChange(false);
};
const confirmDisabled = computed(() => {
if (props.min === undefined) {
return false;
}
return selected.value.length < props.min;
});
const confirmCountMessage = computed(() => {
if (!props.min && !props.max) {
return selected.value.length;
}
return `${selected.value.length} / ${props.max || props.min}`;
});
</script>
<template>
<VModal
:visible="visible"
:width="1240"
:mount-to-body="true"
:layer-closable="true"
:title="$t('core.attachment.select_modal.title')"
height="calc(100vh - 20px)"
@update:visible="onVisibleChange"
>
<VTabbar
v-model:active-id="activeId"
:items="
attachmentSelectProviders.map((provider) => ({
id: provider.id,
label: provider.label,
}))
"
class="w-full"
type="outline"
></VTabbar>
<div v-if="visible" class="mt-2">
<template
v-for="(provider, index) in attachmentSelectProviders"
:key="index"
>
<Suspense>
<component
:is="provider.component"
v-if="activeId === provider.id"
v-model:selected="selected"
:accepts="accepts"
:min="min"
:max="max"
@change-provider="onChangeProvider"
></component>
<template #fallback>
{{ $t("core.common.status.loading") }}
</template>
</Suspense>
</template>
</div>
<template #footer>
<VSpace>
<VButton
type="secondary"
:disabled="confirmDisabled"
@click="handleConfirm"
>
{{ $t("core.common.buttons.confirm") }}
<span v-if="selected.length || props.min || props.max">
{{
$t("core.attachment.select_modal.operations.select.result", {
count: confirmCountMessage,
})
}}
</span>
</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { PolicyTemplate } from "@halo-dev/api-client";
import {
IconAddCircle,
VAlert,
VDropdown,
VDropdownItem,
VModal,
} from "@halo-dev/components";
import { useLocalStorage } from "@vueuse/core";
import { onMounted, ref } from "vue";
import { useFetchAttachmentGroup } from "../composables/use-attachment-group";
import {
useFetchAttachmentPolicy,
useFetchAttachmentPolicyTemplate,
} from "../composables/use-attachment-policy";
import AttachmentGroupBadge from "./AttachmentGroupBadge.vue";
import AttachmentPolicyBadge from "./AttachmentPolicyBadge.vue";
import AttachmentPolicyEditingModal from "./AttachmentPolicyEditingModal.vue";
const emit = defineEmits<{
(event: "close"): void;
}>();
const { groups } = useFetchAttachmentGroup();
const { policies, handleFetchPolicies } = useFetchAttachmentPolicy();
const { policyTemplates } = useFetchAttachmentPolicyTemplate();
const modal = ref<InstanceType<typeof VModal> | null>(null);
const selectedGroupName = useLocalStorage("attachment-upload-group", "");
const selectedPolicyName = useLocalStorage("attachment-upload-policy", "");
const policyEditingModal = ref(false);
const policyTemplateNameToCreate = ref();
onMounted(() => {
if (!selectedPolicyName.value) {
selectedPolicyName.value = policies.value?.[0].metadata.name;
}
});
const handleOpenCreateNewPolicyModal = (policyTemplate: PolicyTemplate) => {
policyTemplateNameToCreate.value = policyTemplate.metadata.name;
policyEditingModal.value = true;
};
const onEditingModalClose = async () => {
await handleFetchPolicies();
policyTemplateNameToCreate.value = undefined;
selectedPolicyName.value = policies.value?.[0].metadata.name;
policyEditingModal.value = false;
};
</script>
<template>
<VModal
ref="modal"
:body-class="['!p-0']"
:width="920"
height="calc(100vh - 20px)"
:title="$t('core.attachment.upload_modal.title')"
mount-to-body
@close="emit('close')"
>
<div class="w-full p-4">
<div class="mb-2">
<span class="text-sm text-gray-800">
{{ $t("core.attachment.upload_modal.filters.policy.label") }}
</span>
</div>
<div class="mb-3 grid grid-cols-2 gap-x-2 gap-y-3 sm:grid-cols-4">
<AttachmentPolicyBadge
v-for="policy in policies"
:key="policy.metadata.name"
:policy="policy"
:is-selected="selectedPolicyName === policy.metadata.name"
:features="{ checkIcon: true }"
@click="selectedPolicyName = policy.metadata.name"
/>
<VDropdown>
<AttachmentPolicyBadge>
<template #text>
<span>{{ $t("core.common.buttons.new") }}</span>
</template>
<template #actions>
<IconAddCircle />
</template>
</AttachmentPolicyBadge>
<template #popper>
<VDropdownItem
v-for="(policyTemplate, index) in policyTemplates"
:key="index"
@click="handleOpenCreateNewPolicyModal(policyTemplate)"
>
{{ policyTemplate.spec?.displayName }}
</VDropdownItem>
</template>
</VDropdown>
</div>
<div v-if="!policies?.length" class="mb-3">
<VAlert
:title="$t('core.attachment.upload_modal.filters.policy.empty.title')"
:description="
$t('core.attachment.upload_modal.filters.policy.empty.description')
"
:closable="false"
/>
</div>
<div class="mb-2">
<span class="text-sm text-gray-800">
{{ $t("core.attachment.upload_modal.filters.group.label") }}
</span>
</div>
<div class="mb-3 grid grid-cols-2 gap-x-2 gap-y-3 sm:grid-cols-4">
<AttachmentGroupBadge
v-for="group in [
{
metadata: { name: '' },
apiVersion: '',
kind: '',
spec: {
displayName: $t('core.attachment.common.text.ungrouped'),
},
},
...(groups || []),
]"
:key="group.metadata.name"
:group="group"
:is-selected="group.metadata.name === selectedGroupName"
:features="{ actions: false, checkIcon: true }"
@click="selectedGroupName = group.metadata.name"
>
</AttachmentGroupBadge>
</div>
<UppyUpload
endpoint="/apis/api.console.halo.run/v1alpha1/attachments/upload"
:disabled="!selectedPolicyName"
:meta="{
policyName: selectedPolicyName,
groupName: selectedGroupName,
}"
width="100%"
:allowed-meta-fields="['policyName', 'groupName']"
:note="
selectedPolicyName
? ''
: $t('core.attachment.upload_modal.filters.policy.not_select')
"
:done-button-handler="() => modal?.close()"
/>
</div>
</VModal>
<AttachmentPolicyEditingModal
v-if="policyEditingModal"
:template-name="policyTemplateNameToCreate"
@close="onEditingModalClose"
/>
</template>

View File

@ -0,0 +1,250 @@
<script lang="ts" setup>
import LazyImage from "@/components/image/LazyImage.vue";
import { isImage } from "@/utils/image";
import { matchMediaTypes } from "@/utils/media-type";
import type { Attachment, Group } from "@halo-dev/api-client";
import {
IconArrowLeft,
IconArrowRight,
IconCheckboxCircle,
IconCheckboxFill,
IconEye,
IconUpload,
VButton,
VCard,
VEmpty,
VPagination,
VSpace,
} from "@halo-dev/components";
import type { AttachmentLike } from "@halo-dev/console-shared";
import { computed, ref, watchEffect } from "vue";
import { useAttachmentControl } from "../../composables/use-attachment";
import AttachmentDetailModal from "../AttachmentDetailModal.vue";
import AttachmentGroupList from "../AttachmentGroupList.vue";
import AttachmentUploadModal from "../AttachmentUploadModal.vue";
const props = withDefaults(
defineProps<{
selected: AttachmentLike[];
accepts?: string[];
min?: number;
max?: number;
}>(),
{
selected: () => [],
accepts: () => ["*/*"],
min: undefined,
max: undefined,
}
);
const emit = defineEmits<{
(event: "update:selected", attachments: AttachmentLike[]): void;
(event: "change-provider", providerId: string): void;
}>();
const selectedGroup = ref();
const page = ref(1);
const size = ref(20);
const {
attachments,
isLoading,
total,
selectedAttachment,
selectedAttachments,
handleFetchAttachments,
handleSelect,
handleSelectPrevious,
handleSelectNext,
handleReset,
isChecked,
} = useAttachmentControl({
groupName: selectedGroup,
accepts: computed(() => {
return props.accepts;
}),
page,
size,
});
const uploadVisible = ref(false);
const detailVisible = ref(false);
watchEffect(() => {
emit("update:selected", Array.from(selectedAttachments.value));
});
const handleOpenDetail = (attachment: Attachment) => {
selectedAttachment.value = attachment;
detailVisible.value = true;
};
const isDisabled = (attachment: Attachment) => {
const isMatchMediaType = matchMediaTypes(
attachment.spec.mediaType || "*/*",
props.accepts
);
if (
props.max !== undefined &&
props.max <= selectedAttachments.value.size &&
!isChecked(attachment)
) {
return true;
}
return !isMatchMediaType;
};
function onUploadModalClose() {
handleFetchAttachments();
uploadVisible.value = false;
}
function onGroupSelect(group: Group) {
selectedGroup.value = group.metadata.name;
handleReset();
}
</script>
<template>
<AttachmentGroupList readonly @select="onGroupSelect" />
<div v-if="attachments?.length" class="mb-5">
<VButton @click="uploadVisible = true">
<template #icon>
<IconUpload class="h-full w-full" />
</template>
{{ $t("core.common.buttons.upload") }}
</VButton>
</div>
<VEmpty
v-if="!attachments?.length && !isLoading"
:message="$t('core.attachment.empty.message')"
:title="$t('core.attachment.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchAttachments">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="secondary" @click="uploadVisible = true">
<template #icon>
<IconUpload class="h-full w-full" />
</template>
{{ $t("core.attachment.empty.actions.upload") }}
</VButton>
</VSpace>
</template>
</VEmpty>
<div
v-else
class="mt-2 grid grid-cols-3 gap-x-2 gap-y-3 sm:grid-cols-3 md:grid-cols-5"
role="list"
>
<VCard
v-for="(attachment, index) in attachments"
:key="index"
:body-class="['!p-0']"
:class="{
'ring-1 ring-primary': isChecked(attachment),
'pointer-events-none !cursor-not-allowed opacity-50':
isDisabled(attachment),
}"
class="hover:shadow"
@click.stop="handleSelect(attachment)"
>
<div class="group relative bg-white">
<div
class="aspect-h-8 aspect-w-10 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
>
<LazyImage
v-if="isImage(attachment.spec.mediaType)"
:key="attachment.metadata.name"
:alt="attachment.spec.displayName"
:src="attachment.status?.permalink"
classes="pointer-events-none object-cover group-hover:opacity-75 transform-gpu"
>
<template #loading>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
<AttachmentFileTypeIcon
v-else
:file-name="attachment.spec.displayName"
/>
</div>
<p
class="pointer-events-none block truncate px-2 py-1 text-center text-xs font-medium text-gray-700"
>
{{ attachment.spec.displayName }}
</p>
<div
:class="{ '!flex': selectedAttachments.has(attachment) }"
class="absolute left-0 top-0 hidden h-1/3 w-full justify-end bg-gradient-to-b from-gray-300 to-transparent ease-in-out group-hover:flex"
>
<IconEye
class="mr-1 mt-1 hidden h-6 w-6 cursor-pointer text-white transition-all hover:text-primary group-hover:block"
@click.stop="handleOpenDetail(attachment)"
/>
<IconCheckboxFill
:class="{
'!text-primary': selectedAttachments.has(attachment),
}"
class="mr-1 mt-1 h-6 w-6 cursor-pointer text-white transition-all hover:text-primary"
/>
</div>
</div>
</VCard>
</div>
<div class="mt-4">
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 50, 100]"
/>
</div>
<AttachmentUploadModal v-if="uploadVisible" @close="onUploadModalClose" />
<AttachmentDetailModal
v-model:visible="detailVisible"
:mount-to-body="true"
:attachment="selectedAttachment"
@close="selectedAttachment = undefined"
>
<template #actions>
<span
v-if="selectedAttachment && selectedAttachments.has(selectedAttachment)"
@click="handleSelect(selectedAttachment)"
>
<IconCheckboxFill />
</span>
<span v-else @click="handleSelect(selectedAttachment)">
<IconCheckboxCircle />
</span>
<span @click="handleSelectPrevious">
<IconArrowLeft />
</span>
<span @click="handleSelectNext">
<IconArrowRight />
</span>
</template>
</AttachmentDetailModal>
</template>

View File

@ -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<Group[] | undefined>;
isLoading: Ref<boolean>;
handleFetchGroups: () => void;
}
export function useFetchAttachmentGroup(): useFetchAttachmentGroupReturn {
const { data, isLoading, refetch } = useQuery<Group[]>({
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,
};
}

View File

@ -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<Policy[] | undefined>;
isLoading: Ref<boolean>;
handleFetchPolicies: () => void;
}
interface useFetchAttachmentPolicyTemplatesReturn {
policyTemplates: Ref<PolicyTemplate[] | undefined>;
isLoading: Ref<boolean>;
handleFetchPolicyTemplates: () => void;
}
export function useFetchAttachmentPolicy(): useFetchAttachmentPolicyReturn {
const { data, isLoading, refetch } = useQuery<Policy[]>({
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<PolicyTemplate[]>({
queryKey: ["attachment-policy-templates"],
queryFn: async () => {
const { data } =
await coreApiClient.storage.policyTemplate.listPolicyTemplate();
return data.items;
},
});
return {
policyTemplates: data,
isLoading,
handleFetchPolicyTemplates: refetch,
};
}

View File

@ -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<Attachment[] | undefined>;
isLoading: Ref<boolean>;
isFetching: Ref<boolean>;
selectedAttachment: Ref<Attachment | undefined>;
selectedAttachments: Ref<Set<Attachment>>;
checkedAll: Ref<boolean>;
total: Ref<number>;
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<string | undefined>;
groupName?: Ref<string | undefined>;
user?: Ref<string | undefined>;
accepts?: Ref<string[]>;
keyword?: Ref<string | undefined>;
sort?: Ref<string | undefined>;
page: Ref<number>;
size: Ref<number>;
}): useAttachmentControlReturn {
const { t } = useI18n();
const { user, policyName, groupName, keyword, sort, page, size, accepts } =
filterOptions;
const selectedAttachment = ref<Attachment>();
const selectedAttachments = ref<Set<Attachment>>(new Set<Attachment>());
const checkedAll = ref(false);
const total = ref(0);
const hasPrevious = ref(false);
const hasNext = ref(false);
const { data, isLoading, isFetching, refetch } = useQuery<Attachment[]>({
queryKey: [
"attachments",
policyName,
keyword,
groupName,
user,
accepts,
page,
size,
sort,
],
queryFn: async () => {
const isUnGrouped = groupName?.value === "ungrouped";
const fieldSelectorMap: Record<string, string | undefined> = {
"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<Attachment | undefined>
) {
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 `<img src="${permalink}" alt="${displayName}" />`;
} else if (isVideo.value) {
return `<video src="${permalink}"></video>`;
} else if (isAudio.value) {
return `<audio src="${permalink}"></audio>`;
}
return `<a href="${permalink}">${displayName}</a>`;
});
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,
};
}

View File

@ -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,
},
],
},
],
});

View File

@ -0,0 +1,413 @@
<script lang="ts" setup>
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
import type { ListedComment } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconMessage,
IconRefreshLine,
Toast,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import CommentListItem from "./components/CommentListItem.vue";
const { t } = useI18n();
const checkAll = ref(false);
const selectedComment = ref<ListedComment>();
const selectedCommentNames = ref<string[]>([]);
const keyword = useRouteQuery<string>("keyword", "");
const selectedApprovedStatus = useRouteQuery<
string | undefined,
boolean | undefined
>("approved", undefined, {
transform: (value) => {
return value ? value === "true" : undefined;
},
});
const selectedSort = useRouteQuery<string | undefined>("sort");
const selectedUser = useRouteQuery<string | undefined>("user");
watch(
() => [
selectedApprovedStatus.value,
selectedSort.value,
selectedUser.value,
keyword.value,
],
() => {
page.value = 1;
}
);
const hasFilters = computed(() => {
return (
selectedApprovedStatus.value !== undefined ||
selectedSort.value ||
selectedUser.value
);
});
function handleClearFilters() {
selectedApprovedStatus.value = undefined;
selectedSort.value = undefined;
selectedUser.value = undefined;
}
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const total = ref(0);
const {
data: comments,
isLoading,
isFetching,
refetch,
} = useQuery<ListedComment[]>({
queryKey: [
"comments",
page,
size,
selectedApprovedStatus,
selectedSort,
selectedUser,
keyword,
],
queryFn: async () => {
const fieldSelectorMap: Record<string, string | boolean | undefined> = {
"spec.approved": selectedApprovedStatus.value,
};
const fieldSelector = Object.entries(fieldSelectorMap)
.map(([key, value]) => {
if (value !== undefined) {
return `${key}=${value}`;
}
})
.filter(Boolean) as string[];
const { data } = await consoleApiClient.content.comment.listComments({
fieldSelector,
page: page.value,
size: size.value,
sort: [selectedSort.value].filter(Boolean) as string[],
keyword: keyword.value,
ownerName: selectedUser.value,
// TODO: email users are not supported at the moment.
ownerKind: selectedUser.value ? "User" : undefined,
});
total.value = data.total;
return data.items;
},
refetchInterval(data) {
const hasDeletingData = data?.some(
(comment) => !!comment.comment.metadata.deletionTimestamp
);
return hasDeletingData ? 1000 : false;
},
});
// Selection
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedCommentNames.value =
comments.value?.map((comment) => {
return comment.comment.metadata.name;
}) || [];
} else {
selectedCommentNames.value = [];
}
};
const checkSelection = (comment: ListedComment) => {
return (
comment.comment.metadata.name ===
selectedComment.value?.comment.metadata.name ||
selectedCommentNames.value.includes(comment.comment.metadata.name)
);
};
watch(
() => selectedCommentNames.value,
(newValue) => {
checkAll.value = newValue.length === comments.value?.length;
}
);
const handleDeleteInBatch = async () => {
Dialog.warning({
title: t("core.comment.operations.delete_comment_in_batch.title"),
description: t(
"core.comment.operations.delete_comment_in_batch.description"
),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const promises = selectedCommentNames.value.map((name) => {
return coreApiClient.content.comment.deleteComment({
name,
});
});
await Promise.all(promises);
selectedCommentNames.value = [];
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete comments", e);
} finally {
refetch();
}
},
});
};
const handleApproveInBatch = async () => {
Dialog.warning({
title: t("core.comment.operations.approve_comment_in_batch.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const commentsToUpdate = comments.value?.filter((comment) => {
return (
selectedCommentNames.value.includes(
comment.comment.metadata.name
) && !comment.comment.spec.approved
);
});
const promises = commentsToUpdate?.map((comment) => {
return coreApiClient.content.comment.patchComment({
name: comment.comment.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/approved",
value: true,
},
{
op: "add",
path: "/spec/approvedTime",
// TODO: see https://github.com/halo-dev/halo/pull/2746
value: new Date().toISOString(),
},
],
});
});
await Promise.all(promises || []);
selectedCommentNames.value = [];
Toast.success(t("core.common.toast.operation_success"));
} catch (e) {
console.error("Failed to approve comments in batch", e);
} finally {
refetch();
}
},
});
};
</script>
<template>
<VPageHeader :title="$t('core.comment.title')">
<template #icon>
<IconMessage class="mr-2 self-center" />
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<div
v-permission="['system:comments:manage']"
class="hidden items-center sm:flex"
>
<input
v-model="checkAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput
v-if="!selectedCommentNames.length"
v-model="keyword"
/>
<VSpace v-else>
<VButton type="secondary" @click="handleApproveInBatch">
{{
$t(
"core.comment.operations.approve_comment_in_batch.button"
)
}}
</VButton>
<VButton type="danger" @click="handleDeleteInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div>
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedApprovedStatus"
:label="$t('core.common.filters.labels.status')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
},
{
label: t('core.comment.filters.status.items.approved'),
value: true,
},
{
label: t(
'core.comment.filters.status.items.pending_review'
),
value: false,
},
]"
/>
<HasPermission :permissions="['system:users:view']">
<UserFilterDropdown
v-model="selectedUser"
:label="$t('core.comment.filters.owner.label')"
/>
</HasPermission>
<FilterDropdown
v-model="selectedSort"
:label="$t('core.common.filters.labels.sort')"
:items="[
{
label: t('core.common.filters.item_labels.default'),
},
{
label: t(
'core.comment.filters.sort.items.last_reply_time_desc'
),
value: 'status.lastReplyTime,desc',
},
{
label: t(
'core.comment.filters.sort.items.last_reply_time_asc'
),
value: 'status.lastReplyTime,asc',
},
{
label: t(
'core.comment.filters.sort.items.reply_count_desc'
),
value: 'status.replyCount,desc',
},
{
label: t('core.comment.filters.sort.items.reply_count_asc'),
value: 'status.replyCount,asc',
},
{
label: t(
'core.comment.filters.sort.items.create_time_desc'
),
value: 'metadata.creationTimestamp,desc',
},
{
label: t('core.comment.filters.sort.items.create_time_asc'),
value: 'metadata.creationTimestamp,asc',
},
]"
/>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!comments?.length" appear name="fade">
<VEmpty
:message="$t('core.comment.empty.message')"
:title="$t('core.comment.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="comment in comments" :key="comment.comment.metadata.name">
<CommentListItem
:comment="comment"
:is-selected="checkSelection(comment)"
>
<template #checkbox>
<input
v-model="selectedCommentNames"
:value="comment?.comment?.metadata.name"
name="comment-checkbox"
type="checkbox"
/>
</template>
</CommentListItem>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>

View File

@ -0,0 +1,508 @@
<script lang="ts" setup>
import { usePluginModuleStore } from "@/stores/plugin";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type {
Extension,
ListedComment,
ListedReply,
Post,
SinglePage,
} from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconAddCircle,
IconExternalLinkLine,
Toast,
VAvatar,
VButton,
VDropdownItem,
VEmpty,
VEntity,
VEntityField,
VLoading,
VSpace,
VStatusDot,
VTag,
} from "@halo-dev/components";
import type {
CommentSubjectRefProvider,
CommentSubjectRefResult,
OperationItem,
} from "@halo-dev/console-shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import {
computed,
onMounted,
provide,
ref,
type Ref,
toRefs,
markRaw,
} from "vue";
import { useI18n } from "vue-i18n";
import ReplyCreationModal from "./ReplyCreationModal.vue";
import ReplyListItem from "./ReplyListItem.vue";
import { useOperationItemExtensionPoint } from "@console/composables/use-operation-extension-points";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
comment: ListedComment;
isSelected?: boolean;
}>(),
{
isSelected: false,
}
);
const { comment } = toRefs(props);
const hoveredReply = ref<ListedReply>();
const showReplies = ref(false);
const replyModal = ref(false);
provide<Ref<ListedReply | undefined>>("hoveredReply", hoveredReply);
const handleDelete = async () => {
Dialog.warning({
title: t("core.comment.operations.delete_comment.title"),
description: t("core.comment.operations.delete_comment.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await coreApiClient.content.comment.deleteComment({
name: props.comment?.comment?.metadata.name as string,
});
Toast.success(t("core.common.toast.delete_success"));
} catch (error) {
console.error("Failed to delete comment", error);
} finally {
queryClient.invalidateQueries({ queryKey: ["comments"] });
}
},
});
};
const handleApproveReplyInBatch = async () => {
Dialog.warning({
title: t("core.comment.operations.approve_applies_in_batch.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const repliesToUpdate = replies.value?.filter((reply) => {
return !reply.reply.spec.approved;
});
const promises = repliesToUpdate?.map((reply) => {
return coreApiClient.content.reply.patchReply({
name: reply.reply.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/approved",
value: true,
},
{
op: "add",
path: "/spec/approvedTime",
// TODO: see https://github.com/halo-dev/halo/pull/2746
value: new Date().toISOString(),
},
],
});
});
await Promise.all(promises || []);
Toast.success(t("core.common.toast.operation_success"));
} catch (e) {
console.error("Failed to approve comment replies in batch", e);
} finally {
await refetch();
}
},
});
};
const handleApprove = async () => {
try {
await coreApiClient.content.comment.patchComment({
name: props.comment.comment.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/approved",
value: true,
},
{
op: "add",
path: "/spec/approvedTime",
// TODO: see https://github.com/halo-dev/halo/pull/2746
value: new Date().toISOString(),
},
],
});
Toast.success(t("core.common.toast.operation_success"));
} catch (error) {
console.error("Failed to approve comment", error);
} finally {
queryClient.invalidateQueries({ queryKey: ["comments"] });
}
};
const {
data: replies,
isLoading,
refetch,
} = useQuery<ListedReply[]>({
queryKey: [
"comment-replies",
props.comment.comment.metadata.name,
showReplies,
],
queryFn: async () => {
const { data } = await consoleApiClient.content.reply.listReplies({
commentName: props.comment.comment.metadata.name,
page: 0,
size: 0,
});
return data.items;
},
refetchInterval(data) {
const deletingReplies = data?.filter(
(reply) => !!reply.reply.metadata.deletionTimestamp
);
return deletingReplies?.length ? 1000 : false;
},
enabled: computed(() => showReplies.value),
});
const { mutateAsync: updateCommentLastReadTimeMutate } = useMutation({
mutationKey: ["update-comment-last-read-time"],
mutationFn: async () => {
return coreApiClient.content.comment.patchComment(
{
name: props.comment.comment.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/lastReadTime",
value: new Date().toISOString(),
},
],
},
{
mute: true,
}
);
},
retry: 3,
});
const handleToggleShowReplies = async () => {
showReplies.value = !showReplies.value;
if (props.comment.comment.status?.unreadReplyCount) {
await updateCommentLastReadTimeMutate();
}
queryClient.invalidateQueries({ queryKey: ["comments"] });
};
const onReplyCreationModalClose = () => {
queryClient.invalidateQueries({ queryKey: ["comments"] });
if (showReplies.value) {
refetch();
}
replyModal.value = false;
};
// Subject ref processing
const SubjectRefProviders = ref<CommentSubjectRefProvider[]>([
{
kind: "Post",
group: "content.halo.run",
resolve: (subject: Extension): CommentSubjectRefResult => {
const post = subject as Post;
return {
label: t("core.comment.subject_refs.post"),
title: post.spec.title,
externalUrl: post.status?.permalink,
route: {
name: "PostEditor",
query: {
name: post.metadata.name,
},
},
};
},
},
{
kind: "SinglePage",
group: "content.halo.run",
resolve: (subject: Extension): CommentSubjectRefResult => {
const singlePage = subject as SinglePage;
return {
label: t("core.comment.subject_refs.page"),
title: singlePage.spec.title,
externalUrl: singlePage.status?.permalink,
route: {
name: "SinglePageEditor",
query: {
name: singlePage.metadata.name,
},
},
};
},
},
]);
const { pluginModules } = usePluginModuleStore();
onMounted(() => {
for (const pluginModule of pluginModules) {
const callbackFunction =
pluginModule?.extensionPoints?.["comment:subject-ref:create"];
if (typeof callbackFunction !== "function") {
continue;
}
const providers = callbackFunction();
SubjectRefProviders.value.push(...providers);
}
});
const subjectRefResult = computed(() => {
const { subject } = props.comment;
if (!subject) {
return {
label: t("core.comment.subject_refs.unknown"),
title: t("core.comment.subject_refs.unknown"),
};
}
const subjectRef = SubjectRefProviders.value.find(
(provider) =>
provider.kind === subject.kind &&
subject.apiVersion.startsWith(provider.group)
);
if (!subjectRef) {
return {
label: t("core.comment.subject_refs.unknown"),
title: t("core.comment.subject_refs.unknown"),
};
}
return subjectRef.resolve(subject);
});
const { operationItems } = useOperationItemExtensionPoint<ListedComment>(
"comment:list-item:operation:create",
comment,
computed((): OperationItem<ListedComment>[] => [
{
priority: 0,
component: markRaw(VDropdownItem),
label: t("core.comment.operations.approve_comment_in_batch.button"),
action: handleApprove,
hidden: props.comment?.comment.spec.approved,
},
{
priority: 10,
component: markRaw(VDropdownItem),
label: t("core.comment.operations.approve_applies_in_batch.button"),
action: handleApproveReplyInBatch,
},
{
priority: 20,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.delete"),
action: handleDelete,
},
])
);
</script>
<template>
<ReplyCreationModal
v-if="replyModal"
:comment="comment"
@close="onReplyCreationModalClose"
/>
<VEntity :is-selected="isSelected" :class="{ 'hover:bg-white': showReplies }">
<template v-if="showReplies" #prepend>
<div class="absolute inset-y-0 left-0 w-[1px] bg-black/50"></div>
<div class="absolute inset-y-0 right-0 w-[1px] bg-black/50"></div>
<div class="absolute inset-x-0 top-0 h-[1px] bg-black/50"></div>
<div class="absolute inset-x-0 bottom-0 h-[1px] bg-black/50"></div>
</template>
<template
v-if="currentUserHasPermission(['system:comments:manage'])"
#checkbox
>
<slot name="checkbox" />
</template>
<template #start>
<VEntityField>
<template #description>
<VAvatar
circle
:src="comment?.owner.avatar"
:alt="comment?.owner.displayName"
size="md"
></VAvatar>
</template>
</VEntityField>
<VEntityField
class="w-28 min-w-[7rem]"
:title="comment?.owner?.displayName"
:description="comment?.owner?.email"
></VEntityField>
<VEntityField width="100%">
<template #description>
<div class="flex flex-col gap-2">
<div class="mb-1 flex items-center gap-2">
<VTag>{{ subjectRefResult.label }}</VTag>
<RouterLink
:to="subjectRefResult.route || $route"
class="line-clamp-2 inline-block text-sm font-medium text-gray-900 hover:text-gray-600"
>
{{ subjectRefResult.title }}
</RouterLink>
<a
v-if="subjectRefResult.externalUrl"
:href="subjectRefResult.externalUrl"
target="_blank"
class="hidden text-gray-600 hover:text-gray-900 group-hover:block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</div>
<div class="break-all text-sm text-gray-900">
{{ comment?.comment?.spec.content }}
</div>
<div class="flex items-center gap-3 text-xs">
<span
class="select-none text-gray-700 hover:text-gray-900"
@click="handleToggleShowReplies"
>
{{
$t("core.comment.list.fields.reply_count", {
count: comment?.comment?.status?.replyCount || 0,
})
}}
</span>
<VStatusDot
v-show="(comment?.comment?.status?.unreadReplyCount || 0) > 0"
v-tooltip="$t('core.comment.list.fields.has_new_replies')"
state="success"
animate
/>
<span
class="select-none text-gray-700 hover:text-gray-900"
@click="replyModal = true"
>
{{ $t("core.comment.operations.reply.button") }}
</span>
</div>
</div>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="!comment?.comment.spec.approved">
<template #description>
<VStatusDot state="success">
<template #text>
<span class="text-xs text-gray-500">
{{ $t("core.comment.list.fields.pending_review") }}
</span>
</template>
</VStatusDot>
</template>
</VEntityField>
<VEntityField v-if="comment?.comment?.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{
formatDatetime(
comment?.comment.spec.creationTime ||
comment?.comment.metadata.creationTimestamp
)
}}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:comments:manage'])"
#dropdownItems
>
<EntityDropdownItems :dropdown-items="operationItems" :item="comment" />
</template>
<template v-if="showReplies" #footer>
<!-- Replies -->
<div
class="ml-8 mt-3 divide-y divide-gray-100 rounded-base border-t border-gray-100 pt-3"
>
<VLoading v-if="isLoading" />
<Transition v-else-if="!replies?.length" appear name="fade">
<VEmpty
:message="$t('core.comment.reply_empty.message')"
:title="$t('core.comment.reply_empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="secondary" @click="replyModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.comment.reply_empty.new") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<div>
<ReplyListItem
v-for="reply in replies"
:key="reply.reply.metadata.name"
:class="{ 'hover:bg-white': showReplies }"
:reply="reply"
:comment="comment"
:replies="replies"
></ReplyListItem>
</div>
</Transition>
</div>
</template>
</VEntity>
</template>

View File

@ -0,0 +1,158 @@
<script lang="ts" setup>
import SubmitButton from "@/components/button/SubmitButton.vue";
import type {
ListedComment,
ListedReply,
ReplyRequest,
} from "@halo-dev/api-client";
import {
IconMotionLine,
Toast,
VButton,
VDropdown,
VModal,
VSpace,
} from "@halo-dev/components";
// @ts-ignore
import { setFocus } from "@/formkit/utils/focus";
import i18n from "@emoji-mart/data/i18n/zh.json";
import { consoleApiClient } from "@halo-dev/api-client";
import { Picker } from "emoji-mart";
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
comment?: ListedComment;
reply?: ListedReply;
}>(),
{
visible: false,
comment: undefined,
reply: undefined,
}
);
const emit = defineEmits<{
(event: "close"): void;
}>();
const modal = ref<InstanceType<typeof VModal> | null>(null);
const formState = ref<ReplyRequest>({
raw: "",
content: "",
allowNotification: true,
quoteReply: undefined,
});
const saving = ref(false);
onMounted(() => {
setFocus("content-input");
});
const handleCreateReply = async () => {
try {
saving.value = true;
if (props.reply) {
formState.value.quoteReply = props.reply.reply.metadata.name;
}
formState.value.content = formState.value.raw;
await consoleApiClient.content.comment.createReply({
name: props.comment?.comment.metadata.name as string,
replyRequest: formState.value,
});
modal.value?.close();
Toast.success(
t("core.comment.reply_modal.operations.submit.toast_success")
);
} catch (error) {
console.error("Failed to create comment reply", error);
} finally {
saving.value = false;
}
};
// Emoji picker
const emojiPickerRef = ref<HTMLElement | null>(null);
const handleCreateEmojiPicker = async () => {
if (emojiPickerRef.value?.childElementCount) {
return;
}
const data = await import("@emoji-mart/data");
const emojiPicker = new Picker({
data: Object.assign({}, data),
theme: "light",
autoFocus: true,
i18n: i18n,
onEmojiSelect: onEmojiSelect,
});
emojiPickerRef.value?.appendChild(emojiPicker as unknown as Node);
};
const onEmojiSelect = (emoji: { native: string }) => {
formState.value.raw += emoji.native;
setFocus("content-input");
};
</script>
<template>
<VModal
ref="modal"
:title="$t('core.comment.reply_modal.title')"
:width="500"
@close="emit('close')"
>
<FormKit
id="create-reply-form"
name="create-reply-form"
type="form"
:config="{ validationVisibility: 'submit' }"
@submit="handleCreateReply"
>
<FormKit
id="content-input"
v-model="formState.raw"
type="textarea"
:validation-label="$t('core.comment.reply_modal.fields.content.label')"
:rows="6"
value=""
validation="required|length:0,1024"
></FormKit>
</FormKit>
<div class="mt-2 flex justify-end">
<VDropdown :classes="['!p-0']" @show="handleCreateEmojiPicker">
<IconMotionLine
class="h-5 w-5 cursor-pointer text-gray-500 transition-all hover:text-gray-900"
/>
<template #popper>
<div ref="emojiPickerRef"></div>
</template>
</VDropdown>
</div>
<template #footer>
<VSpace>
<SubmitButton
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('create-reply-form')"
>
</SubmitButton>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,250 @@
<script lang="ts" setup>
import { formatDatetime } from "@/utils/date";
import type { ListedComment, ListedReply } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconReplyLine,
Toast,
VAvatar,
VDropdownItem,
VEntity,
VEntityField,
VStatusDot,
VTag,
} from "@halo-dev/components";
import type { OperationItem } from "@halo-dev/console-shared";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, inject, ref, type Ref, toRefs, markRaw } from "vue";
import { useI18n } from "vue-i18n";
import ReplyCreationModal from "./ReplyCreationModal.vue";
import { useOperationItemExtensionPoint } from "@console/composables/use-operation-extension-points";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
comment: ListedComment;
reply: ListedReply;
replies?: ListedReply[];
}>(),
{
reply: undefined,
replies: undefined,
}
);
const { reply } = toRefs(props);
const quoteReply = computed(() => {
const { quoteReply: replyName } = props.reply.reply.spec;
if (!replyName) {
return undefined;
}
return props.replies?.find(
(reply) => reply.reply.metadata.name === replyName
);
});
const handleDelete = async () => {
Dialog.warning({
title: t("core.comment.operations.delete_reply.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 coreApiClient.content.reply.deleteReply({
name: props.reply?.reply.metadata.name as string,
});
Toast.success(t("core.common.toast.delete_success"));
} catch (error) {
console.error("Failed to delete comment reply", error);
} finally {
queryClient.invalidateQueries({ queryKey: ["comment-replies"] });
}
},
});
};
const handleApprove = async () => {
try {
await coreApiClient.content.reply.patchReply({
name: props.reply.reply.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/approved",
value: true,
},
{
op: "add",
path: "/spec/approvedTime",
// TODO: see https://github.com/halo-dev/halo/pull/2746
value: new Date().toISOString(),
},
],
});
Toast.success(t("core.common.toast.operation_success"));
} catch (error) {
console.error("Failed to approve comment reply", error);
} finally {
queryClient.invalidateQueries({ queryKey: ["comment-replies"] });
}
};
// Show hovered reply
const hoveredReply = inject<Ref<ListedReply | undefined>>("hoveredReply");
const handleShowQuoteReply = (show: boolean) => {
if (hoveredReply) {
hoveredReply.value = show ? quoteReply.value : undefined;
}
};
const isHoveredReply = computed(() => {
return (
hoveredReply?.value?.reply.metadata.name === props.reply.reply.metadata.name
);
});
// Create reply
const replyModal = ref(false);
function onReplyCreationModalClose() {
queryClient.invalidateQueries({
queryKey: ["comment-replies", props.comment.comment.metadata.name],
});
replyModal.value = false;
}
const { operationItems } = useOperationItemExtensionPoint<ListedReply>(
"reply:list-item:operation:create",
reply,
computed((): OperationItem<ListedReply>[] => [
{
priority: 0,
component: markRaw(VDropdownItem),
label: t("core.comment.operations.approve_reply.button"),
permissions: ["system:comments:manage"],
action: handleApprove,
hidden: props.reply?.reply.spec.approved,
},
{
priority: 10,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.delete"),
permissions: ["system:comments:manage"],
action: handleDelete,
},
])
);
</script>
<template>
<ReplyCreationModal
v-if="replyModal"
:comment="comment"
:reply="reply"
@close="onReplyCreationModalClose"
/>
<VEntity class="!px-0 !py-2" :class="{ 'animate-breath': isHoveredReply }">
<template #start>
<VEntityField>
<template #description>
<VAvatar
circle
:src="reply?.owner.avatar"
:alt="reply?.owner.displayName"
size="md"
></VAvatar>
</template>
</VEntityField>
<VEntityField
class="w-28 min-w-[7rem]"
:title="reply?.owner.displayName"
:description="reply?.owner.email"
></VEntityField>
<VEntityField width="60%">
<template #description>
<div class="flex flex-col gap-2">
<div class="text-sm text-gray-800">
<p class="break-all">
<a
v-if="quoteReply"
class="mr-1 inline-flex flex-row items-center gap-1 rounded bg-gray-200 px-1 py-0.5 text-xs font-medium text-gray-600 hover:text-blue-500 hover:underline"
href="javascript:void(0)"
@mouseenter="handleShowQuoteReply(true)"
@mouseleave="handleShowQuoteReply(false)"
>
<IconReplyLine />
<span>{{ quoteReply.owner.displayName }}</span>
</a>
<br v-if="quoteReply" />
{{ reply?.reply.spec.content }}
</p>
</div>
<div class="flex items-center gap-3 text-xs">
<span
class="select-none text-gray-700 hover:text-gray-900"
@click="replyModal = true"
>
{{ $t("core.comment.operations.reply.button") }}
</span>
<div v-if="false" class="flex items-center">
<VTag>New</VTag>
</div>
</div>
</div>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="!reply?.reply.spec.approved">
<template #description>
<VStatusDot state="success">
<template #text>
<span class="text-xs text-gray-500">
{{ $t("core.comment.list.fields.pending_review") }}
</span>
</template>
</VStatusDot>
</template>
</VEntityField>
<VEntityField v-if="reply?.reply.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{
formatDatetime(
reply?.reply?.spec.creationTime ||
reply?.reply.metadata.creationTimestamp
)
}}
</span>
</template>
</VEntityField>
</template>
<template #dropdownItems>
<EntityDropdownItems :dropdown-items="operationItems" :item="reply" />
</template>
</VEntity>
</template>

View File

@ -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,
},
],
},
],
});

View File

@ -0,0 +1,28 @@
<script lang="ts" setup>
import { useDashboardStats } from "@console/composables/use-dashboard-stats";
import { IconMessage, VCard } from "@halo-dev/components";
const { data: stats } = useDashboardStats();
</script>
<template>
<VCard class="h-full" :body-class="['h-full']">
<div class="flex h-full">
<div class="flex items-center gap-4">
<span
class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconMessage class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">
{{ $t("core.dashboard.widgets.presets.comment_stats.title") }}
</span>
<p class="text-2xl font-medium text-gray-900">
{{ stats?.approvedComments }}
</p>
</div>
</div>
</div>
</VCard>
</template>

View File

@ -0,0 +1,402 @@
<script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconAddCircle,
IconDeleteBin,
IconRefreshLine,
Toast,
VButton,
VCard,
VDropdownItem,
VEmpty,
VEntity,
VEntityField,
VLoading,
VPageHeader,
VPagination,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const selectedPageNames = ref<string[]>([]);
const checkedAll = ref(false);
const keyword = ref("");
const page = ref(1);
const size = ref(20);
const total = ref(0);
const {
data: singlePages,
isLoading,
isFetching,
refetch,
} = useQuery<ListedSinglePage[]>({
queryKey: ["deleted-singlePages", page, size, keyword],
queryFn: async () => {
const { data } = await consoleApiClient.content.singlePage.listSinglePages({
labelSelector: [`content.halo.run/deleted=true`],
page: page.value,
size: size.value,
keyword: keyword.value,
});
total.value = data.total;
return data.items;
},
refetchInterval(data) {
const deletedSinglePages = data?.filter(
(singlePage) =>
!!singlePage.page.metadata.deletionTimestamp ||
!singlePage.page.spec.deleted
);
return deletedSinglePages?.length ? 1000 : false;
},
});
const checkSelection = (singlePage: SinglePage) => {
return selectedPageNames.value.includes(singlePage.metadata.name);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPageNames.value =
singlePages.value?.map((singlePage) => {
return singlePage.page.metadata.name;
}) || [];
} else {
selectedPageNames.value = [];
}
};
const handleDeletePermanently = async (singlePage: SinglePage) => {
Dialog.warning({
title: t("core.deleted_page.operations.delete.title"),
description: t("core.deleted_page.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await coreApiClient.content.singlePage.deleteSinglePage({
name: singlePage.metadata.name,
});
await refetch();
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleDeletePermanentlyInBatch = async () => {
Dialog.warning({
title: t("core.deleted_page.operations.delete_in_batch.title"),
description: t("core.deleted_page.operations.delete_in_batch.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
return coreApiClient.content.singlePage.deleteSinglePage({
name,
});
})
);
await refetch();
selectedPageNames.value = [];
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleRecovery = async (singlePage: SinglePage) => {
Dialog.warning({
title: t("core.deleted_page.operations.recovery.title"),
description: t("core.deleted_page.operations.recovery.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await coreApiClient.content.singlePage.patchSinglePage({
name: singlePage.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/deleted",
value: false,
},
],
});
await refetch();
Toast.success(t("core.common.toast.recovery_success"));
},
});
};
const handleRecoveryInBatch = async () => {
Dialog.warning({
title: t("core.deleted_page.operations.recovery_in_batch.title"),
description: t(
"core.deleted_page.operations.recovery_in_batch.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
const singlePage = singlePages.value?.find(
(item) => item.page.metadata.name === name
)?.page;
if (!singlePage) {
return Promise.resolve();
}
return coreApiClient.content.singlePage.patchSinglePage({
name: singlePage.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/deleted",
value: false,
},
],
});
})
);
await refetch();
selectedPageNames.value = [];
Toast.success(t("core.common.toast.recovery_success"));
},
});
};
watch(selectedPageNames, (newValue) => {
checkedAll.value = newValue.length === singlePages.value?.length;
});
watch(
() => keyword.value,
() => {
page.value = 1;
}
);
</script>
<template>
<VPageHeader :title="$t('core.deleted_page.title')">
<template #icon>
<IconDeleteBin class="mr-2 self-center text-green-600" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'SinglePages' }" size="sm">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
type="secondary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<div
v-permission="['system:singlepages:manage']"
class="hidden items-center sm:flex"
>
<input
v-model="checkedAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput v-if="!selectedPageNames.length" v-model="keyword" />
<VSpace v-else>
<VButton type="danger" @click="handleDeletePermanentlyInBatch">
{{ $t("core.common.buttons.delete_permanently") }}
</VButton>
<VButton type="default" @click="handleRecoveryInBatch">
{{ $t("core.common.buttons.recovery") }}
</VButton>
</VSpace>
</div>
<VSpace spacing="lg" class="flex-wrap">
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!singlePages?.length" appear name="fade">
<VEmpty
:message="$t('core.deleted_page.empty.message')"
:title="$t('core.deleted_page.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:singlepages:view']"
:route="{ name: 'SinglePages' }"
type="primary"
>
{{ $t("core.common.buttons.back") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(singlePage, index) in singlePages" :key="index">
<VEntity :is-selected="checkSelection(singlePage.page)">
<template
v-if="currentUserHasPermission(['system:singlepages:manage'])"
#checkbox
>
<input
v-model="selectedPageNames"
:value="singlePage.page.metadata.name"
type="checkbox"
/>
</template>
<template #start>
<VEntityField :title="singlePage.page.spec.title">
<template #description>
<VSpace>
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.visits", {
visits: singlePage.stats.visit || 0,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.comments", {
comments: singlePage.stats.totalComment || 0,
})
}}
</span>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<PostContributorList
:owner="singlePage.owner"
:contributors="singlePage.contributors"
/>
</template>
</VEntityField>
<VEntityField v-if="!singlePage?.page?.spec.deleted">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.tooltips.recovering')"
state="success"
animate
/>
</template>
</VEntityField>
<VEntityField
v-if="singlePage?.page?.metadata.deletionTimestamp"
>
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(singlePage.page.spec.publishTime) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:singlepages:manage'])"
#dropdownItems
>
<VDropdownItem
type="danger"
@click="handleDeletePermanently(singlePage.page)"
>
{{ $t("core.common.buttons.delete_permanently") }}
</VDropdownItem>
<VDropdownItem @click="handleRecovery(singlePage.page)">
{{ $t("core.common.buttons.recovery") }}
</VDropdownItem>
</template>
</VEntity>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>

View File

@ -0,0 +1,546 @@
<script lang="ts" setup>
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import UrlPreviewModal from "@/components/preview/UrlPreviewModal.vue";
import { useAutoSaveContent } from "@/composables/use-auto-save-content";
import { useContentCache } from "@/composables/use-content-cache";
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import { useSessionKeepAlive } from "@/composables/use-session-keep-alive";
import { contentAnnotations } from "@/constants/annotations";
import { randomUUID } from "@/utils/id";
import { usePermission } from "@/utils/permission";
import { useContentSnapshot } from "@console/composables/use-content-snapshot";
import { useSaveKeybinding } from "@console/composables/use-save-keybinding";
import type { SinglePage, SinglePageRequest } from "@halo-dev/api-client";
import {
consoleApiClient,
coreApiClient,
ucApiClient,
} from "@halo-dev/api-client";
import {
Dialog,
IconEye,
IconHistoryLine,
IconPages,
IconSave,
IconSendPlaneFill,
IconSettings,
Toast,
VButton,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import type { EditorProvider } from "@halo-dev/console-shared";
import { useLocalStorage } from "@vueuse/core";
import { useRouteQuery } from "@vueuse/router";
import type { AxiosRequestConfig } from "axios";
import {
computed,
nextTick,
onMounted,
provide,
ref,
toRef,
watch,
type ComputedRef,
} from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
import { usePageUpdateMutate } from "./composables/use-page-update-mutate";
const router = useRouter();
const { t } = useI18n();
const { mutateAsync: singlePageUpdateMutate } = usePageUpdateMutate();
const { currentUserHasPermission } = usePermission();
// Editor providers
const { editorProviders, fetchEditorProviders } = useEditorExtensionPoints();
const currentEditorProvider = ref<EditorProvider>();
const storedEditorProviderName = useLocalStorage("editor-provider-name", "");
const handleChangeEditorProvider = async (provider: EditorProvider) => {
currentEditorProvider.value = provider;
storedEditorProviderName.value = provider.name;
formState.value.page.metadata.annotations = {
...formState.value.page.metadata.annotations,
[contentAnnotations.PREFERRED_EDITOR]: provider.name,
};
formState.value.content.rawType = provider.rawType;
if (isUpdateMode.value) {
const { data } = await singlePageUpdateMutate(formState.value.page);
formState.value.page = data;
}
};
// SinglePage form
const formState = ref<SinglePageRequest>({
page: {
spec: {
title: "",
slug: "",
template: "",
cover: "",
deleted: false,
publish: false,
publishTime: undefined,
pinned: false,
allowComment: true,
visible: "PUBLIC",
priority: 0,
excerpt: {
autoGenerate: true,
raw: "",
},
htmlMetas: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "SinglePage",
metadata: {
name: randomUUID(),
annotations: {},
},
},
content: {
raw: "",
content: "",
rawType: "HTML",
},
});
const saving = ref(false);
const publishing = ref(false);
const settingModal = ref(false);
const isTitleChanged = ref(false);
watch(
() => formState.value.page.spec.title,
(newValue, oldValue) => {
isTitleChanged.value = newValue !== oldValue;
}
);
const isUpdateMode = computed(() => {
return !!formState.value.page.metadata.creationTimestamp;
});
// provide some data to editor
provide<ComputedRef<string | undefined>>(
"owner",
computed(() => formState.value.page.spec.owner)
);
provide<ComputedRef<string | undefined>>(
"publishTime",
computed(() => formState.value.page.spec.publishTime)
);
provide<ComputedRef<string | undefined>>(
"permalink",
computed(() => formState.value.page.status?.permalink)
);
const routeQueryName = useRouteQuery<string>("name");
const handleSave = async (options?: { mute?: boolean }) => {
try {
if (!options?.mute) {
saving.value = true;
}
//Set default title and slug
if (!formState.value.page.spec.title) {
formState.value.page.spec.title = t("core.page_editor.untitled");
}
if (!formState.value.page.spec.slug) {
formState.value.page.spec.slug = new Date().getTime().toString();
}
if (isUpdateMode.value) {
if (isTitleChanged.value) {
formState.value.page = (
await singlePageUpdateMutate(formState.value.page)
).data;
}
const { data } =
await consoleApiClient.content.singlePage.updateSinglePageContent({
name: formState.value.page.metadata.name,
content: formState.value.content,
});
formState.value.page = data;
isTitleChanged.value = false;
} else {
// Clear new page content cache
handleClearCache();
const { data } =
await consoleApiClient.content.singlePage.draftSinglePage({
singlePageRequest: formState.value,
});
formState.value.page = data;
routeQueryName.value = data.metadata.name;
}
if (!options?.mute) {
Toast.success(t("core.common.toast.save_success"));
}
handleClearCache(formState.value.page.metadata.name as string);
await handleFetchContent();
await handleFetchSnapshot();
} catch (error) {
console.error("Failed to save single page", error);
Toast.error(t("core.common.toast.save_failed_and_retry"));
} finally {
saving.value = false;
}
};
const returnToView = useRouteQuery<string>("returnToView");
const handlePublish = async () => {
try {
publishing.value = true;
if (isUpdateMode.value) {
const { name: singlePageName } = formState.value.page.metadata;
const { permalink } = formState.value.page.status || {};
if (isTitleChanged.value) {
formState.value.page = (
await singlePageUpdateMutate(formState.value.page)
).data;
}
await consoleApiClient.content.singlePage.updateSinglePageContent({
name: singlePageName,
content: formState.value.content,
});
await consoleApiClient.content.singlePage.publishSinglePage({
name: singlePageName,
});
if (returnToView.value && permalink) {
window.location.href = permalink;
} else {
router.back();
}
} else {
formState.value.page.spec.publish = true;
await consoleApiClient.content.singlePage.draftSinglePage({
singlePageRequest: formState.value,
});
// Clear new page content cache
handleClearCache();
router.push({ name: "SinglePages" });
}
Toast.success(t("core.common.toast.publish_success"));
handleClearCache(routeQueryName.value as string);
} catch (error) {
console.error("Failed to publish single page", error);
Toast.error(t("core.common.toast.publish_failed_and_retry"));
} finally {
publishing.value = false;
}
};
const handlePublishClick = () => {
if (isUpdateMode.value) {
handlePublish();
} else {
// Set editor title to page
settingModal.value = true;
}
};
const handleFetchContent = async () => {
if (!formState.value.page.spec.headSnapshot) {
return;
}
const { data } =
await consoleApiClient.content.singlePage.fetchSinglePageHeadContent({
name: formState.value.page.metadata.name,
});
formState.value.content = Object.assign(formState.value.content, data);
// get editor provider
if (!currentEditorProvider.value) {
const preferredEditor = editorProviders.value.find(
(provider) =>
provider.name ===
formState.value.page.metadata.annotations?.[
contentAnnotations.PREFERRED_EDITOR
]
);
const provider =
preferredEditor ||
editorProviders.value.find(
(provider) =>
provider.rawType.toLowerCase() === data.rawType?.toLowerCase()
);
if (provider) {
currentEditorProvider.value = provider;
formState.value.page.metadata.annotations = {
...formState.value.page.metadata.annotations,
[contentAnnotations.PREFERRED_EDITOR]: provider.name,
};
const { data } = await singlePageUpdateMutate(formState.value.page);
formState.value.page = data;
} else {
Dialog.warning({
title: t("core.common.dialog.titles.warning"),
description: t("core.common.dialog.descriptions.editor_not_found", {
raw_type: data.rawType,
}),
confirmText: t("core.common.buttons.confirm"),
showCancel: false,
onConfirm: () => {
router.back();
},
});
}
await nextTick();
}
};
// SinglePage settings
const handleOpenSettingModal = async () => {
if (isTitleChanged.value) {
await coreApiClient.content.singlePage.patchSinglePage({
name: formState.value.page.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/title",
value:
formState.value.page.spec.title || t("core.page_editor.untitled"),
},
],
});
isTitleChanged.value = false;
}
const { data: latestSinglePage } =
await coreApiClient.content.singlePage.getSinglePage({
name: formState.value.page.metadata.name,
});
formState.value.page = latestSinglePage;
settingModal.value = true;
};
const onSettingSaved = (page: SinglePage) => {
// Set route query parameter
if (!isUpdateMode.value) {
routeQueryName.value = page.metadata.name;
}
formState.value.page = page;
if (!isUpdateMode.value) {
handleSave();
}
};
const onSettingPublished = (singlePage: SinglePage) => {
formState.value.page = singlePage;
handlePublish();
};
onMounted(async () => {
await fetchEditorProviders();
if (routeQueryName.value) {
const { data: singlePage } =
await coreApiClient.content.singlePage.getSinglePage({
name: routeQueryName.value,
});
formState.value.page = singlePage;
// fetch single page content
await handleFetchContent();
} else {
// Set default editor
const provider =
editorProviders.value.find(
(provider) => provider.name === storedEditorProviderName.value
) || editorProviders.value[0];
if (provider) {
currentEditorProvider.value = provider;
formState.value.content.rawType = provider.rawType;
}
formState.value.page.metadata.annotations = {
[contentAnnotations.PREFERRED_EDITOR]: provider.name,
};
}
handleResetCache();
});
const headSnapshot = computed(() => {
return formState.value.page.spec.headSnapshot;
});
const { version, handleFetchSnapshot } = useContentSnapshot(headSnapshot);
// SinglePage content cache
const {
currentCache,
handleSetContentCache,
handleResetCache,
handleClearCache,
} = useContentCache(
"singlePage-content-cache",
routeQueryName,
toRef(formState.value.content, "raw"),
version
);
useAutoSaveContent(currentCache, toRef(formState.value.content, "raw"), () => {
// Do not save when the setting modal is open
if (settingModal.value) {
return;
}
handleSave({ mute: true });
});
// SinglePage preview
const previewModal = ref(false);
const previewPending = ref(false);
const handlePreview = async () => {
previewPending.value = true;
await handleSave({ mute: true });
previewModal.value = true;
previewPending.value = false;
};
useSaveKeybinding(handleSave);
// Keep session alive
useSessionKeepAlive();
// Upload image
async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
if (!currentUserHasPermission(["uc:attachments:manage"])) {
return;
}
const { data } = await ucApiClient.storage.attachment.createAttachmentForPost(
{
file,
singlePageName: formState.value.page.metadata.name,
waitForPermalink: true,
},
options
);
return data;
}
</script>
<template>
<SinglePageSettingModal
v-if="settingModal"
:single-page="formState.page"
:publish-support="!isUpdateMode"
:only-emit="!isUpdateMode"
@close="settingModal = false"
@saved="onSettingSaved"
@published="onSettingPublished"
/>
<UrlPreviewModal
v-if="previewModal"
:title="formState.page.spec.title"
:url="`/preview/singlepages/${formState.page.metadata.name}`"
@close="previewModal = false"
/>
<VPageHeader :title="$t('core.page.title')">
<template #icon>
<IconPages class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<EditorProviderSelector
v-if="editorProviders.length > 1"
:provider="currentEditorProvider"
:allow-forced-select="!isUpdateMode"
@select="handleChangeEditorProvider"
/>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="
$router.push({
name: 'SinglePageSnapshots',
query: { name: routeQueryName },
})
"
>
<template #icon>
<IconHistoryLine class="h-full w-full" />
</template>
{{ $t("core.page_editor.actions.snapshots") }}
</VButton>
<VButton
size="sm"
type="default"
:loading="previewPending"
@click="handlePreview"
>
<template #icon>
<IconEye class="h-full w-full" />
</template>
{{ $t("core.common.buttons.preview") }}
</VButton>
<VButton :loading="saving" size="sm" type="default" @click="handleSave">
<template #icon>
<IconSave class="h-full w-full" />
</template>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="handleOpenSettingModal"
>
<template #icon>
<IconSettings class="h-full w-full" />
</template>
{{ $t("core.common.buttons.setting") }}
</VButton>
<VButton
type="secondary"
:loading="publishing"
@click="handlePublishClick"
>
<template #icon>
<IconSendPlaneFill class="h-full w-full" />
</template>
{{ $t("core.common.buttons.publish") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
<component
:is="currentEditorProvider.component"
v-if="currentEditorProvider"
v-model:raw="formState.content.raw"
v-model:content="formState.content.content"
v-model:title="formState.page.spec.title"
:upload-image="handleUploadImage"
class="h-full"
@update="handleSetContentCache"
/>
</div>
</template>

View File

@ -0,0 +1,479 @@
<script lang="ts" setup>
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
import { singlePageLabels } from "@/constants/labels";
import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconAddCircle,
IconArrowLeft,
IconArrowRight,
IconPages,
IconRefreshLine,
Toast,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import type { Ref } from "vue";
import { computed, provide, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import SinglePageListItem from "./components/SinglePageListItem.vue";
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
const { t } = useI18n();
const settingModal = ref(false);
const selectedSinglePage = ref<SinglePage>();
const selectedPageNames = ref<string[]>([]);
const checkedAll = ref(false);
provide<Ref<string[]>>("selectedPageNames", selectedPageNames);
// Filters
const selectedContributor = useRouteQuery<string | undefined>("contributor");
const selectedVisible = useRouteQuery<
"PUBLIC" | "INTERNAL" | "PRIVATE" | undefined
>("visible");
const selectedPublishStatus = useRouteQuery<string | undefined>("status");
const selectedSort = useRouteQuery<string | undefined>("sort");
const keyword = useRouteQuery<string>("keyword", "");
watch(
() => [
selectedContributor.value,
selectedVisible.value,
selectedPublishStatus.value,
selectedSort.value,
keyword.value,
],
() => {
page.value = 1;
}
);
const hasFilters = computed(() => {
return (
selectedContributor.value ||
selectedVisible.value ||
selectedPublishStatus.value !== undefined ||
selectedSort.value
);
});
function handleClearFilters() {
selectedContributor.value = undefined;
selectedVisible.value = undefined;
selectedPublishStatus.value = undefined;
selectedSort.value = undefined;
}
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const total = ref(0);
const hasNext = ref(false);
const hasPrevious = ref(false);
const {
data: singlePages,
isLoading,
isFetching,
refetch,
} = useQuery<ListedSinglePage[]>({
queryKey: [
"singlePages",
selectedContributor,
selectedPublishStatus,
page,
size,
selectedVisible,
selectedSort,
keyword,
],
queryFn: async () => {
let contributors: string[] | undefined;
const labelSelector: string[] = ["content.halo.run/deleted=false"];
if (selectedContributor.value) {
contributors = [selectedContributor.value];
}
if (selectedPublishStatus.value !== undefined) {
labelSelector.push(
`${singlePageLabels.PUBLISHED}=${selectedPublishStatus.value}`
);
}
const { data } = await consoleApiClient.content.singlePage.listSinglePages({
labelSelector,
page: page.value,
size: size.value,
visible: selectedVisible.value,
sort: [selectedSort.value].filter(Boolean) as string[],
keyword: keyword.value,
contributor: contributors,
});
total.value = data.total;
hasNext.value = data.hasNext;
hasPrevious.value = data.hasPrevious;
return data.items;
},
refetchInterval(data) {
const hasAbnormalSinglePage = data?.some((singlePage) => {
const { spec, metadata } = singlePage.page;
return (
spec.deleted ||
metadata.labels?.[singlePageLabels.PUBLISHED] !== spec.publish + ""
);
});
return hasAbnormalSinglePage ? 1000 : false;
},
});
const handleOpenSettingModal = async (singlePage: SinglePage) => {
const { data } = await coreApiClient.content.singlePage.getSinglePage({
name: singlePage.metadata.name,
});
selectedSinglePage.value = data;
settingModal.value = true;
};
const onSettingModalClose = () => {
selectedSinglePage.value = undefined;
settingModal.value = false;
refetch();
};
const handleSelectPrevious = async () => {
if (!singlePages.value) return;
const index = singlePages.value.findIndex(
(singlePage) =>
singlePage.page.metadata.name === selectedSinglePage.value?.metadata.name
);
if (index > 0) {
const { data } = await coreApiClient.content.singlePage.getSinglePage({
name: singlePages.value[index - 1].page.metadata.name,
});
selectedSinglePage.value = data;
return;
}
if (index === 0 && hasPrevious.value) {
page.value--;
await refetch();
selectedSinglePage.value =
singlePages.value[singlePages.value.length - 1].page;
}
};
const handleSelectNext = async () => {
if (!singlePages.value) return;
const index = singlePages.value.findIndex(
(singlePage) =>
singlePage.page.metadata.name === selectedSinglePage.value?.metadata.name
);
if (index < singlePages.value.length - 1) {
const { data } = await coreApiClient.content.singlePage.getSinglePage({
name: singlePages.value[index + 1].page.metadata.name,
});
selectedSinglePage.value = data;
return;
}
if (index === singlePages.value.length - 1 && hasNext.value) {
page.value++;
await refetch();
selectedSinglePage.value = singlePages.value[0].page;
}
};
const checkSelection = (singlePage: SinglePage) => {
return (
singlePage.metadata.name === selectedSinglePage.value?.metadata.name ||
selectedPageNames.value.includes(singlePage.metadata.name)
);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPageNames.value =
singlePages.value?.map((singlePage) => {
return singlePage.page.metadata.name;
}) || [];
} else {
selectedPageNames.value = [];
}
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: t("core.page.operations.delete_in_batch.title"),
description: t("core.page.operations.delete_in_batch.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
const page = singlePages.value?.find(
(item) => item.page.metadata.name === name
)?.page;
if (!page) {
return Promise.resolve();
}
return coreApiClient.content.singlePage.patchSinglePage({
name: page.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/deleted",
value: true,
},
],
});
})
);
await refetch();
selectedPageNames.value = [];
Toast.success(t("core.common.toast.delete_success"));
},
});
};
watch(selectedPageNames, (newValue) => {
checkedAll.value = newValue.length === singlePages.value?.length;
});
</script>
<template>
<SinglePageSettingModal
v-if="settingModal"
:single-page="selectedSinglePage"
@close="onSettingModalClose"
>
<template #actions>
<span @click="handleSelectPrevious">
<IconArrowLeft v-tooltip="$t('core.common.buttons.previous')" />
</span>
<span @click="handleSelectNext">
<IconArrowRight v-tooltip="$t('core.common.buttons.next')" />
</span>
</template>
</SinglePageSettingModal>
<VPageHeader :title="$t('core.page.title')">
<template #icon>
<IconPages class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton
v-permission="['system:singlepages:view']"
:route="{ name: 'DeletedSinglePages' }"
size="sm"
>
{{ $t("core.page.actions.recycle_bin") }}
</VButton>
<VButton
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
type="secondary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<div
v-permission="['system:singlepages:manage']"
class="hidden items-center sm:flex"
>
<input
v-model="checkedAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput v-if="!selectedPageNames.length" v-model="keyword" />
<VSpace v-else>
<VButton type="danger" @click="handleDeleteInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div>
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedPublishStatus"
:label="$t('core.common.filters.labels.status')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
value: undefined,
},
{
label: t('core.page.filters.status.items.published'),
value: 'true',
},
{
label: t('core.page.filters.status.items.draft'),
value: 'false',
},
]"
/>
<FilterDropdown
v-model="selectedVisible"
:label="$t('core.page.filters.visible.label')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
value: undefined,
},
{
label: t('core.page.filters.visible.items.public'),
value: 'PUBLIC',
},
{
label: t('core.page.filters.visible.items.private'),
value: 'PRIVATE',
},
]"
/>
<HasPermission :permissions="['system:users:view']">
<UserFilterDropdown
v-model="selectedContributor"
:label="$t('core.page.filters.author.label')"
/>
</HasPermission>
<FilterDropdown
v-model="selectedSort"
:label="$t('core.common.filters.labels.sort')"
:items="[
{
label: t('core.common.filters.item_labels.default'),
},
{
label: t('core.page.filters.sort.items.publish_time_desc'),
value: 'publishTime,desc',
},
{
label: t('core.page.filters.sort.items.publish_time_asc'),
value: 'publishTime,asc',
},
{
label: t('core.page.filters.sort.items.create_time_desc'),
value: 'creationTimestamp,desc',
},
{
label: t('core.page.filters.sort.items.create_time_asc'),
value: 'creationTimestamp,asc',
},
]"
/>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!singlePages?.length" appear name="fade">
<VEmpty
:message="$t('core.page.empty.message')"
:title="$t('core.page.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
type="primary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li
v-for="singlePage in singlePages"
:key="singlePage.page.metadata.name"
>
<SinglePageListItem
:single-page="singlePage"
:is-selected="checkSelection(singlePage.page)"
@open-setting-modal="handleOpenSettingModal"
/>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>

View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconHistoryLine,
Toast,
VButton,
VCard,
VLoading,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import SnapshotContent from "./components/SnapshotContent.vue";
import SnapshotListItem from "./components/SnapshotListItem.vue";
const queryClient = useQueryClient();
const route = useRoute();
const { t } = useI18n();
const singlePageName = computed(() => route.query.name as string);
const { data: singlePage } = useQuery({
queryKey: ["singlePage-by-name", singlePageName],
queryFn: async () => {
const { data } = await coreApiClient.content.singlePage.getSinglePage({
name: singlePageName.value,
});
return data;
},
enabled: computed(() => !!singlePageName.value),
});
const { data: snapshots, isLoading } = useQuery({
queryKey: ["singlePage-snapshots-by-singlePage-name", singlePageName],
queryFn: async () => {
const { data } =
await consoleApiClient.content.singlePage.listSinglePageSnapshots({
name: singlePageName.value,
});
return data;
},
refetchInterval(data) {
const hasDeletingData = data?.some(
(item) => !!item.metadata.deletionTimestamp
);
return hasDeletingData ? 1000 : false;
},
enabled: computed(() => !!singlePageName.value),
});
const selectedSnapshotName = useRouteQuery<string | undefined>("snapshot-name");
watch(
() => snapshots.value,
(value) => {
if (value && !selectedSnapshotName.value) {
selectedSnapshotName.value = value[0].metadata.name;
}
// Reset selectedSnapshotName if the selected snapshot is deleted
if (
!value?.some(
(snapshot) => snapshot.metadata.name === selectedSnapshotName.value
)
) {
selectedSnapshotName.value = value?.[0].metadata.name;
}
},
{
immediate: true,
}
);
function handleCleanup() {
Dialog.warning({
title: t("core.page_snapshots.operations.cleanup.title"),
description: t("core.page_snapshots.operations.cleanup.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
const { releaseSnapshot, baseSnapshot, headSnapshot } =
singlePage.value?.spec || {};
const snapshotsToDelete = snapshots.value?.filter((snapshot) => {
const { name } = snapshot.metadata;
return ![releaseSnapshot, baseSnapshot, headSnapshot]
.filter(Boolean)
.includes(name);
});
if (!snapshotsToDelete?.length) {
Toast.info(t("core.page_snapshots.operations.cleanup.toast_empty"));
return;
}
for (let i = 0; i < snapshotsToDelete?.length; i++) {
await consoleApiClient.content.singlePage.deleteSinglePageContent({
name: singlePageName.value,
snapshotName: snapshotsToDelete[i].metadata.name,
});
}
await queryClient.invalidateQueries({
queryKey: ["singlePage-snapshots-by-singlePage-name", singlePageName],
});
Toast.success(t("core.page_snapshots.operations.cleanup.toast_success"));
},
});
}
</script>
<template>
<VPageHeader :title="singlePage?.spec.title">
<template #icon>
<IconHistoryLine class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton size="sm" @click="$router.back()">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton size="sm" type="danger" @click="handleCleanup">
{{ $t("core.page_snapshots.operations.cleanup.button") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard
style="height: calc(100vh - 5.5rem)"
:body-class="['h-full', '!p-0']"
>
<div class="grid h-full grid-cols-12 divide-y sm:divide-x sm:divide-y-0">
<div
class="relative col-span-12 h-full overflow-auto sm:col-span-6 lg:col-span-3 xl:col-span-2"
>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<VLoading v-if="isLoading" />
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li
v-for="snapshot in snapshots"
:key="snapshot.metadata.name"
@click="selectedSnapshotName = snapshot.metadata.name"
>
<SnapshotListItem
:snapshot="snapshot"
:single-page="singlePage"
:selected-snapshot-name="selectedSnapshotName"
/>
</li>
</ul>
</Transition>
</OverlayScrollbarsComponent>
</div>
<div
class="col-span-12 h-full overflow-auto sm:col-span-6 lg:col-span-9 xl:col-span-10"
>
<SnapshotContent
:single-page-name="singlePageName"
:snapshot-name="selectedSnapshotName"
/>
</div>
</div>
</VCard>
</div>
</template>

View File

@ -0,0 +1,258 @@
<script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import { singlePageLabels } from "@/constants/labels";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconExternalLinkLine,
IconEye,
IconEyeOff,
Toast,
VDropdownDivider,
VDropdownItem,
VEntity,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import type { Ref } from "vue";
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { RouterLink } from "vue-router";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
singlePage: ListedSinglePage;
isSelected?: boolean;
}>(),
{
isSelected: false,
}
);
const emit = defineEmits<{
(event: "open-setting-modal", post: SinglePage): void;
}>();
const selectedPageNames = inject<Ref<string[]>>("selectedPageNames", ref([]));
const externalUrl = computed(() => {
const { metadata, status } = props.singlePage.page;
if (metadata.labels?.[singlePageLabels.PUBLISHED] === "true") {
return status?.permalink;
}
return `/preview/singlepages/${metadata.name}`;
});
const publishStatus = computed(() => {
const { labels } = props.singlePage.page.metadata;
return labels?.[singlePageLabels.PUBLISHED] === "true"
? t("core.page.filters.status.items.published")
: t("core.page.filters.status.items.draft");
});
const isPublishing = computed(() => {
const { spec, status, metadata } = props.singlePage.page;
return (
(spec.publish &&
metadata.labels?.[singlePageLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
const { mutate: changeVisibleMutation } = useMutation({
mutationFn: async (singlePage: SinglePage) => {
return await coreApiClient.content.singlePage.patchSinglePage({
name: singlePage.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/visible",
value: singlePage.spec.visible === "PRIVATE" ? "PUBLIC" : "PRIVATE",
},
],
});
},
retry: 3,
onSuccess: () => {
Toast.success(t("core.common.toast.operation_success"));
queryClient.invalidateQueries({ queryKey: ["singlePages"] });
},
onError: () => {
Toast.error(t("core.common.toast.operation_failed"));
},
});
const handleDelete = async () => {
Dialog.warning({
title: t("core.page.operations.delete.title"),
description: t("core.page.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await coreApiClient.content.singlePage.patchSinglePage({
name: props.singlePage.page.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/deleted",
value: true,
},
],
});
await queryClient.invalidateQueries({ queryKey: ["singlePages"] });
Toast.success(t("core.common.toast.delete_success"));
},
});
};
</script>
<template>
<VEntity :is-selected="isSelected">
<template
v-if="currentUserHasPermission(['system:singlepages:manage'])"
#checkbox
>
<input
v-model="selectedPageNames"
:value="singlePage.page.metadata.name"
type="checkbox"
/>
</template>
<template #start>
<VEntityField
:title="singlePage.page.spec.title"
:route="{
name: 'SinglePageEditor',
query: { name: singlePage.page.metadata.name },
}"
>
<template #extra>
<VSpace>
<RouterLink
v-if="singlePage.page.status?.inProgress"
v-tooltip="$t('core.common.tooltips.unpublished_content_tip')"
:to="{
name: 'SinglePageEditor',
query: { name: singlePage.page.metadata.name },
}"
class="flex items-center"
>
<VStatusDot state="success" animate />
</RouterLink>
<a
target="_blank"
:href="externalUrl"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</VSpace>
</template>
<template #description>
<div class="flex w-full flex-col gap-1">
<VSpace class="w-full">
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.visits", {
visits: singlePage.stats.visit || 0,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.comments", {
comments: singlePage.stats.totalComment || 0,
})
}}
</span>
</VSpace>
</div>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<PostContributorList
:owner="singlePage.owner"
:contributors="singlePage.contributors"
/>
</template>
</VEntityField>
<VEntityField :description="publishStatus">
<template v-if="isPublishing" #description>
<VStatusDot :text="$t('core.common.tooltips.publishing')" animate />
</template>
</VEntityField>
<HasPermission :permissions="['system:singlepages:manage']">
<VEntityField>
<template #description>
<IconEye
v-if="singlePage.page.spec.visible === 'PUBLIC'"
v-tooltip="$t('core.page.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(singlePage.page)"
/>
<IconEyeOff
v-if="singlePage.page.spec.visible === 'PRIVATE'"
v-tooltip="$t('core.page.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(singlePage.page)"
/>
</template>
</VEntityField>
</HasPermission>
<VEntityField v-if="singlePage?.page?.spec.deleted">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(singlePage.page.spec.publishTime) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:singlepages:manage'])"
#dropdownItems
>
<VDropdownItem
@click="
$router.push({
name: 'SinglePageEditor',
query: { name: singlePage.page.metadata.name },
})
"
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem @click="emit('open-setting-modal', singlePage.page)">
{{ $t("core.common.buttons.setting") }}
</VDropdownItem>
<VDropdownDivider />
<VDropdownItem type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
</template>

View File

@ -0,0 +1,490 @@
<script lang="ts" setup>
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { singlePageLabels } from "@/constants/labels";
import { FormType } from "@/types/slug";
import { toDatetimeLocal, toISOString } from "@/utils/date";
import { randomUUID } from "@/utils/id";
import useSlugify from "@console/composables/use-slugify";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import { submitForm } from "@formkit/core";
import type { SinglePage } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
IconRefreshLine,
Toast,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
import { cloneDeep } from "lodash-es";
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { usePageUpdateMutate } from "../composables/use-page-update-mutate";
const props = withDefaults(
defineProps<{
singlePage?: SinglePage;
publishSupport?: boolean;
onlyEmit?: boolean;
}>(),
{
singlePage: undefined,
publishSupport: true,
onlyEmit: false,
}
);
const emit = defineEmits<{
(event: "close"): void;
(event: "saved", singlePage: SinglePage): void;
(event: "published", singlePage: SinglePage): void;
}>();
const { t } = useI18n();
const formState = ref<SinglePage>({
spec: {
title: "",
slug: "",
template: "",
cover: "",
deleted: false,
publish: false,
publishTime: undefined,
pinned: false,
allowComment: true,
visible: "PUBLIC",
priority: 0,
excerpt: {
autoGenerate: true,
raw: "",
},
htmlMetas: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "SinglePage",
metadata: {
name: randomUUID(),
},
});
const modal = ref<InstanceType<typeof VModal> | null>(null);
const isSubmitting = ref(false);
const publishing = ref(false);
const publishCanceling = ref(false);
const submitType = ref<"publish" | "save">();
const publishTime = ref<string | undefined>(undefined);
const isUpdateMode = !!props.singlePage;
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
const handleSubmit = () => {
if (submitType.value === "publish") {
handlePublish();
}
if (submitType.value === "save") {
handleSave();
}
};
const handleSaveClick = () => {
submitType.value = "save";
nextTick(() => {
submitForm("singlePage-setting-form");
});
};
const handlePublishClick = () => {
submitType.value = "publish";
nextTick(() => {
submitForm("singlePage-setting-form");
});
};
// Fix me:
// Force update singlePage settings,
// because currently there may be errors caused by changes in version due to asynchronous processing.
const { mutateAsync: singlePageUpdateMutate } = usePageUpdateMutate();
const handleSave = async () => {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
annotationsFormRef.value || {};
if (customFormInvalid || specFormInvalid) {
return;
}
formState.value.metadata.annotations = {
...annotations,
...customAnnotations,
};
if (props.onlyEmit) {
emit("saved", formState.value);
modal.value?.close();
return;
}
try {
isSubmitting.value = true;
const { data } = isUpdateMode
? await singlePageUpdateMutate(formState.value)
: await coreApiClient.content.singlePage.createSinglePage({
singlePage: formState.value,
});
formState.value = data;
emit("saved", data);
modal.value?.close();
Toast.success(t("core.common.toast.save_success"));
} catch (error) {
console.error("Failed to save single page", error);
} finally {
isSubmitting.value = false;
}
};
const handlePublish = async () => {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
annotationsFormRef.value || {};
if (customFormInvalid || specFormInvalid) {
return;
}
formState.value.metadata.annotations = {
...annotations,
...customAnnotations,
};
if (props.onlyEmit) {
emit("published", formState.value);
modal.value?.close();
return;
}
try {
publishing.value = true;
const singlePageToUpdate = cloneDeep(formState.value);
singlePageToUpdate.spec.releaseSnapshot =
singlePageToUpdate.spec.headSnapshot;
singlePageToUpdate.spec.publish = true;
const { data } = await coreApiClient.content.singlePage.updateSinglePage({
name: formState.value.metadata.name,
singlePage: singlePageToUpdate,
});
formState.value = data;
emit("published", data);
modal.value?.close();
Toast.success(t("core.common.toast.publish_success"));
} catch (error) {
console.error("Failed to publish single page", error);
} finally {
publishing.value = false;
}
};
const handleUnpublish = async () => {
try {
publishCanceling.value = true;
const { data: singlePage } =
await coreApiClient.content.singlePage.getSinglePage({
name: formState.value.metadata.name,
});
const singlePageToUpdate = cloneDeep(singlePage);
singlePageToUpdate.spec.publish = false;
const { data } = await coreApiClient.content.singlePage.updateSinglePage({
name: formState.value.metadata.name,
singlePage: singlePageToUpdate,
});
formState.value = data;
modal.value?.close();
Toast.success(t("core.common.toast.cancel_publish_success"));
} catch (error) {
console.error("Failed to unpublish single page", error);
} finally {
publishCanceling.value = false;
}
};
watch(
() => props.singlePage,
(value) => {
if (value) {
formState.value = cloneDeep(value);
publishTime.value = toDatetimeLocal(formState.value.spec.publishTime);
}
},
{
immediate: true,
}
);
watch(
() => publishTime.value,
(value) => {
formState.value.spec.publishTime = value ? toISOString(value) : undefined;
}
);
// custom templates
const { templates } = useThemeCustomTemplates("page");
// slug
const { handleGenerateSlug } = useSlugify(
computed(() => formState.value.spec.title),
computed({
get() {
return formState.value.spec.slug;
},
set(value) {
formState.value.spec.slug = value;
},
}),
computed(() => !isUpdateMode),
FormType.SINGLE_PAGE
);
</script>
<template>
<VModal
ref="modal"
:width="700"
:title="$t('core.page.settings.title')"
:centered="false"
@close="emit('close')"
>
<template #actions>
<slot name="actions"></slot>
</template>
<FormKit
id="singlePage-setting-form"
type="form"
name="singlePage-setting-form"
:config="{ validationVisibility: 'submit' }"
@submit="handleSubmit"
>
<div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.page.settings.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-model="formState.spec.title"
:label="$t('core.page.settings.fields.title.label')"
type="text"
name="title"
validation="required|length:0,100"
></FormKit>
<FormKit
v-model="formState.spec.slug"
:label="$t('core.page.settings.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,100"
:help="$t('core.page.settings.fields.slug.help')"
>
<template #suffix>
<div
v-tooltip="
$t('core.page.settings.fields.slug.refresh_message')
"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug(true, FormType.SINGLE_PAGE)"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
v-model="formState.spec.excerpt.autoGenerate"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
name="autoGenerate"
:label="
$t('core.page.settings.fields.auto_generate_excerpt.label')
"
type="radio"
>
</FormKit>
<FormKit
v-if="!formState.spec.excerpt.autoGenerate"
v-model="formState.spec.excerpt.raw"
name="raw"
:label="$t('core.page.settings.fields.raw_excerpt.label')"
type="textarea"
validation="length:0,1024"
:rows="5"
></FormKit>
</div>
</div>
<div class="py-5">
<div class="border-t border-gray-200"></div>
</div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.page.settings.groups.advanced") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-model="formState.spec.allowComment"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
name="allowComment"
:label="$t('core.page.settings.fields.allow_comment.label')"
type="radio"
></FormKit>
<FormKit
v-model="formState.spec.pinned"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
:label="$t('core.page.settings.fields.pinned.label')"
name="pinned"
type="radio"
></FormKit>
<FormKit
v-model="formState.spec.visible"
:options="[
{ label: $t('core.common.select.public'), value: 'PUBLIC' },
{
label: $t('core.common.select.private'),
value: 'PRIVATE',
},
]"
:label="$t('core.page.settings.fields.visible.label')"
name="visible"
type="select"
></FormKit>
<FormKit
v-model="publishTime"
:label="$t('core.page.settings.fields.publish_time.label')"
type="datetime-local"
name="publishTime"
min="0000-01-01T00:00"
max="9999-12-31T23:59"
></FormKit>
<FormKit
v-model="formState.spec.template"
:options="templates"
:label="$t('core.page.settings.fields.template.label')"
type="select"
name="template"
></FormKit>
<FormKit
v-model="formState.spec.cover"
:label="$t('core.page.settings.fields.cover.label')"
type="attachment"
name="cover"
:accepts="['image/*']"
validation="length:0,1024"
></FormKit>
</div>
</div>
</div>
</FormKit>
<div class="py-5">
<div class="border-t border-gray-200"></div>
</div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.page.settings.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<AnnotationsForm
:key="formState.metadata.name"
ref="annotationsFormRef"
:value="formState.metadata.annotations"
kind="SinglePage"
group="content.halo.run"
/>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between">
<VSpace>
<VButton
v-if="
publishSupport &&
formState.metadata.labels?.[singlePageLabels.PUBLISHED] !== 'true'
"
:loading="publishing"
type="secondary"
@click="handlePublishClick()"
>
{{ $t("core.common.buttons.publish") }}
</VButton>
<VButton
:loading="isSubmitting"
type="secondary"
@click="handleSaveClick"
>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton type="default" @click="modal?.close()">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
<VButton
v-if="
formState.metadata.labels?.[singlePageLabels.PUBLISHED] === 'true'
"
:loading="publishCanceling"
type="danger"
@click="handleUnpublish()"
>
{{ $t("core.common.buttons.cancel_publish") }}
</VButton>
</div>
</template>
</VModal>
</template>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { consoleApiClient } from "@halo-dev/api-client";
import { Toast, VLoading } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { computed, toRefs } from "vue";
const props = withDefaults(
defineProps<{
singlePageName?: string;
snapshotName?: string;
}>(),
{
singlePageName: undefined,
snapshotName: undefined,
}
);
const { singlePageName, snapshotName } = toRefs(props);
const { data: snapshot, isLoading } = useQuery({
queryKey: ["singlePage-snapshot-by-name", singlePageName, snapshotName],
queryFn: async () => {
if (!singlePageName.value || !snapshotName.value) {
throw new Error("singlePageName and snapshotName are required");
}
const { data } =
await consoleApiClient.content.singlePage.fetchSinglePageContent({
name: singlePageName.value,
snapshotName: snapshotName.value,
});
return data;
},
onError(err) {
if (err instanceof Error) {
Toast.error(err.message);
}
},
enabled: computed(() => !!singlePageName.value && !!snapshotName.value),
});
</script>
<template>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<VLoading v-if="isLoading" />
<div
v-else
class="snapshot-content markdown-body h-full w-full p-4"
v-html="snapshot?.content"
></div>
</OverlayScrollbarsComponent>
</template>
<style scoped lang="scss">
::v-deep(.snapshot-content) {
p {
margin-top: 0.75em;
margin-bottom: 0;
}
pre {
background: #0d0d0d;
padding: 0.75rem 1rem;
margin: 0;
code {
background: none;
font-size: 0.8rem;
padding: 0 !important;
border-radius: 0;
}
}
ul[data-type="taskList"] {
list-style: none;
padding: 0;
p {
margin: 0;
}
li {
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
}
}
ul {
list-style: disc !important;
}
ol {
list-style: decimal !important;
}
code br {
display: initial;
}
}
</style>

View File

@ -0,0 +1,142 @@
<script setup lang="ts">
import { relativeTimeTo } from "@/utils/date";
import type { ListedSnapshotDto, SinglePage } from "@halo-dev/api-client";
import { consoleApiClient } from "@halo-dev/api-client";
import { Dialog, Toast, VButton, VStatusDot, VTag } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
singlePage?: SinglePage;
snapshot: ListedSnapshotDto;
selectedSnapshotName?: string;
}>(),
{
singlePage: undefined,
selectedSnapshotName: undefined,
}
);
async function handleRestore() {
Dialog.warning({
title: t("core.page_snapshots.operations.revert.title"),
description: t("core.page_snapshots.operations.revert.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await consoleApiClient.content.singlePage.revertToSpecifiedSnapshotForSinglePage(
{
name: props.singlePage?.metadata.name as string,
revertSnapshotForSingleParam: {
snapshotName: props.snapshot.metadata.name,
},
}
);
await queryClient.invalidateQueries({
queryKey: ["singlePage-snapshots-by-singlePage-name"],
});
Toast.success(t("core.page_snapshots.operations.revert.toast_success"));
},
});
}
function handleDelete() {
Dialog.warning({
title: t("core.page_snapshots.operations.delete.title"),
description: t("core.page_snapshots.operations.delete.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await consoleApiClient.content.singlePage.deleteSinglePageContent({
name: props.singlePage?.metadata.name as string,
snapshotName: props.snapshot.metadata.name,
});
await queryClient.invalidateQueries({
queryKey: ["singlePage-snapshots-by-singlePage-name"],
});
Toast.success(t("core.common.toast.delete_success"));
},
});
}
const isSelected = computed(() => {
return props.selectedSnapshotName === props.snapshot.metadata.name;
});
const isReleased = computed(() => {
return (
props.singlePage?.spec.releaseSnapshot === props.snapshot.metadata.name
);
});
const isHead = computed(() => {
const { headSnapshot, releaseSnapshot } = props.singlePage?.spec || {};
return (
headSnapshot !== releaseSnapshot &&
headSnapshot === props.snapshot.metadata.name
);
});
const isBase = computed(() => {
return props.singlePage?.spec.baseSnapshot === props.snapshot.metadata.name;
});
</script>
<template>
<div
class="group relative flex cursor-pointer flex-col gap-5 p-4"
:class="{ 'bg-gray-50': isSelected }"
>
<div
v-if="isSelected"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="flex items-center justify-between">
<div
class="truncate text-sm"
:class="{
'font-semibold': isSelected,
}"
>
{{ relativeTimeTo(snapshot.metadata.creationTimestamp) }}
</div>
<div class="inline-flex flex-none items-center space-x-3">
<VTag v-if="isReleased" theme="primary">
{{ $t("core.page_snapshots.status.released") }}
</VTag>
<VTag v-if="isHead">
{{ $t("core.page_snapshots.status.draft") }}
</VTag>
<VTag v-if="isBase">
{{ $t("core.page_snapshots.status.base") }}
</VTag>
<VStatusDot
v-if="snapshot.metadata.deletionTimestamp"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</div>
</div>
<div class="flex h-6 items-end justify-between gap-2">
<div class="flex-1 truncate text-xs text-gray-600">
{{ snapshot.spec.owner }}
</div>
<div
v-if="!isReleased"
class="hidden flex-none space-x-2 group-hover:block"
>
<VButton v-if="!isHead" size="xs" @click="handleRestore()">
{{ $t("core.page_snapshots.operations.revert.button") }}
</VButton>
<VButton v-if="!isBase" size="xs" type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VButton>
</div>
</div>
</div>
</template>

View File

@ -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"));
},
});
}

View File

@ -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"],
},
},
],
},
],
});

View File

@ -0,0 +1,43 @@
<script lang="ts" setup>
import { singlePageLabels } from "@/constants/labels";
import { consoleApiClient } from "@halo-dev/api-client";
import { IconPages, VCard } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
const { data: total } = useQuery({
queryKey: ["widget-singlePage-count"],
queryFn: async () => {
const { data } = await consoleApiClient.content.singlePage.listSinglePages({
labelSelector: [
`${singlePageLabels.DELETED}=false`,
`${singlePageLabels.PUBLISHED}=true`,
],
page: 0,
size: 0,
});
return data.total;
},
});
</script>
<template>
<VCard class="h-full" :body-class="['h-full']">
<div class="flex h-full">
<div class="flex items-center gap-4">
<span
class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconPages class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">
{{ $t("core.dashboard.widgets.presets.page_stats.title") }}
</span>
<p class="text-2xl font-medium text-gray-900">
{{ total || 0 }}
</p>
</div>
</div>
</div>
</VCard>
</template>

View File

@ -0,0 +1,423 @@
<script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { ListedPost, Post } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconAddCircle,
IconDeleteBin,
IconRefreshLine,
Toast,
VButton,
VCard,
VDropdownItem,
VEmpty,
VEntity,
VEntityField,
VLoading,
VPageHeader,
VPagination,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import PostTag from "./tags/components/PostTag.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]);
const keyword = ref("");
const page = ref(1);
const size = ref(20);
const total = ref(0);
const {
data: posts,
isLoading,
isFetching,
refetch,
} = useQuery<ListedPost[]>({
queryKey: ["deleted-posts", page, size, keyword],
queryFn: async () => {
const { data } = await consoleApiClient.content.post.listPosts({
labelSelector: [`content.halo.run/deleted=true`],
page: page.value,
size: size.value,
keyword: keyword.value,
});
total.value = data.total;
return data.items;
},
refetchInterval: (data) => {
const deletingPosts = data?.some(
(post) =>
!!post.post.metadata.deletionTimestamp || !post.post.spec.deleted
);
return deletingPosts ? 1000 : false;
},
});
const checkSelection = (post: Post) => {
return selectedPostNames.value.includes(post.metadata.name);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPostNames.value =
posts.value?.map((post) => {
return post.post.metadata.name;
}) || [];
} else {
selectedPostNames.value = [];
}
};
const handleDeletePermanently = async (post: Post) => {
Dialog.warning({
title: t("core.deleted_post.operations.delete.title"),
description: t("core.deleted_post.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await coreApiClient.content.post.deletePost({
name: post.metadata.name,
});
await refetch();
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleDeletePermanentlyInBatch = async () => {
Dialog.warning({
title: t("core.deleted_post.operations.delete_in_batch.title"),
description: t("core.deleted_post.operations.delete_in_batch.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
return coreApiClient.content.post.deletePost({
name,
});
})
);
await refetch();
selectedPostNames.value = [];
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleRecovery = async (post: Post) => {
Dialog.warning({
title: t("core.deleted_post.operations.recovery.title"),
description: t("core.deleted_post.operations.recovery.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await coreApiClient.content.post.patchPost({
name: post.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/deleted",
value: false,
},
],
});
await refetch();
Toast.success(t("core.common.toast.recovery_success"));
},
});
};
const handleRecoveryInBatch = async () => {
Dialog.warning({
title: t("core.deleted_post.operations.recovery_in_batch.title"),
description: t(
"core.deleted_post.operations.recovery_in_batch.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
const isPostExist = posts.value?.some(
(item) => item.post.metadata.name === name
);
if (!isPostExist) {
return Promise.resolve();
}
return coreApiClient.content.post.patchPost({
name: name,
jsonPatchInner: [
{
op: "add",
path: "/spec/deleted",
value: false,
},
],
});
})
);
await refetch();
selectedPostNames.value = [];
Toast.success(t("core.common.toast.recovery_success"));
},
});
};
watch(selectedPostNames, (newValue) => {
checkedAll.value = newValue.length === posts.value?.length;
});
watch(
() => keyword.value,
() => {
page.value = 1;
}
);
</script>
<template>
<VPageHeader :title="$t('core.deleted_post.title')">
<template #icon>
<IconDeleteBin class="mr-2 self-center text-green-600" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'Posts' }" size="sm">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
type="secondary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<div
v-permission="['system:posts:manage']"
class="hidden items-center sm:flex"
>
<input
v-model="checkedAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput v-if="!selectedPostNames.length" v-model="keyword" />
<VSpace v-else>
<VButton type="danger" @click="handleDeletePermanentlyInBatch">
{{ $t("core.common.buttons.delete_permanently") }}
</VButton>
<VButton type="default" @click="handleRecoveryInBatch">
{{ $t("core.common.buttons.recovery") }}
</VButton>
</VSpace>
</div>
<VSpace spacing="lg" class="flex-wrap">
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!posts?.length" appear name="fade">
<VEmpty
:message="$t('core.deleted_post.empty.message')"
:title="$t('core.deleted_post.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton :route="{ name: 'Posts' }" type="primary">
{{ $t("core.common.buttons.back") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(post, index) in posts" :key="index">
<VEntity :is-selected="checkSelection(post.post)">
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#checkbox
>
<input
v-model="selectedPostNames"
:value="post.post.metadata.name"
name="post-checkbox"
type="checkbox"
/>
</template>
<template #start>
<VEntityField :title="post.post.spec.title" width="27rem">
<template #description>
<div class="flex flex-col gap-1.5">
<VSpace class="flex-wrap !gap-y-1">
<p
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
{{ $t("core.post.list.fields.categories") }}
<span
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
class="cursor-pointer hover:text-gray-900"
>
{{ category.spec.displayName }}
</span>
</p>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.visits", {
visits: post.stats.visit,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.comments", {
comments: post.stats.totalComment || 0,
})
}}
</span>
</VSpace>
<VSpace v-if="post.tags.length" class="flex-wrap">
<PostTag
v-for="(tag, tagIndex) in post.tags"
:key="tagIndex"
:tag="tag"
route
></PostTag>
</VSpace>
</div>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<PostContributorList
:owner="post.owner"
:contributors="post.contributors"
/>
</template>
</VEntityField>
<VEntityField v-if="!post?.post?.spec.deleted">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.tooltips.recovering')"
state="success"
animate
/>
</template>
</VEntityField>
<VEntityField v-if="post?.post?.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(post.post.spec.publishTime) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems
>
<VDropdownItem
type="danger"
@click="handleDeletePermanently(post.post)"
>
{{ $t("core.common.buttons.delete_permanently") }}
</VDropdownItem>
<VDropdownItem @click="handleRecovery(post.post)">
{{ $t("core.common.buttons.recovery") }}
</VDropdownItem>
</template>
</VEntity>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>

View File

@ -0,0 +1,582 @@
<script lang="ts" setup>
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import UrlPreviewModal from "@/components/preview/UrlPreviewModal.vue";
import { useAutoSaveContent } from "@/composables/use-auto-save-content";
import { useContentCache } from "@/composables/use-content-cache";
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import { useSessionKeepAlive } from "@/composables/use-session-keep-alive";
import { contentAnnotations } from "@/constants/annotations";
import { FormType } from "@/types/slug";
import { randomUUID } from "@/utils/id";
import { usePermission } from "@/utils/permission";
import { useContentSnapshot } from "@console/composables/use-content-snapshot";
import { useSaveKeybinding } from "@console/composables/use-save-keybinding";
import useSlugify from "@console/composables/use-slugify";
import type { Post, PostRequest } from "@halo-dev/api-client";
import {
consoleApiClient,
coreApiClient,
ucApiClient,
} from "@halo-dev/api-client";
import {
Dialog,
IconBookRead,
IconEye,
IconHistoryLine,
IconSave,
IconSendPlaneFill,
IconSettings,
Toast,
VButton,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import type { EditorProvider } from "@halo-dev/console-shared";
import { useLocalStorage } from "@vueuse/core";
import { useRouteQuery } from "@vueuse/router";
import type { AxiosRequestConfig } from "axios";
import {
computed,
nextTick,
onMounted,
provide,
ref,
toRef,
watch,
type ComputedRef,
} from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import PostSettingModal from "./components/PostSettingModal.vue";
import { usePostUpdateMutate } from "./composables/use-post-update-mutate";
const router = useRouter();
const { t } = useI18n();
const { mutateAsync: postUpdateMutate } = usePostUpdateMutate();
const { currentUserHasPermission } = usePermission();
// Editor providers
const { editorProviders, fetchEditorProviders } = useEditorExtensionPoints();
const currentEditorProvider = ref<EditorProvider>();
const storedEditorProviderName = useLocalStorage("editor-provider-name", "");
const handleChangeEditorProvider = async (provider: EditorProvider) => {
currentEditorProvider.value = provider;
storedEditorProviderName.value = provider.name;
formState.value.post.metadata.annotations = {
...formState.value.post.metadata.annotations,
[contentAnnotations.PREFERRED_EDITOR]: provider.name,
};
formState.value.content.rawType = provider.rawType;
if (isUpdateMode.value) {
const { data } = await postUpdateMutate(formState.value.post);
formState.value.post = data;
}
};
// fixme: PostRequest type may be wrong
interface PostRequestWithContent extends PostRequest {
content: {
raw: string;
content: string;
rawType: string;
};
}
// Post form
const formState = ref<PostRequestWithContent>({
post: {
spec: {
title: "",
slug: "",
template: "",
cover: "",
deleted: false,
publish: false,
publishTime: undefined,
pinned: false,
allowComment: true,
visible: "PUBLIC",
priority: 0,
excerpt: {
autoGenerate: true,
raw: "",
},
categories: [],
tags: [],
htmlMetas: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Post",
metadata: {
name: randomUUID(),
annotations: {},
},
},
content: {
raw: "",
content: "",
rawType: "HTML",
},
});
const settingModal = ref(false);
const saving = ref(false);
const publishing = ref(false);
const isTitleChanged = ref(false);
watch(
() => formState.value.post.spec.title,
(newValue, oldValue) => {
isTitleChanged.value = newValue !== oldValue;
}
);
const isUpdateMode = computed(() => {
return !!formState.value.post.metadata.creationTimestamp;
});
// provide some data to editor
provide<ComputedRef<string | undefined>>(
"owner",
computed(() => formState.value.post.spec.owner)
);
provide<ComputedRef<string | undefined>>(
"publishTime",
computed(() => formState.value.post.spec.publishTime)
);
provide<ComputedRef<string | undefined>>(
"permalink",
computed(() => formState.value.post.status?.permalink)
);
const handleSave = async (options?: { mute?: boolean }) => {
try {
if (!options?.mute) {
saving.value = true;
}
// Set default title and slug
if (!formState.value.post.spec.title) {
formState.value.post.spec.title = t("core.post_editor.untitled");
}
if (!formState.value.post.spec.slug) {
formState.value.post.spec.slug = new Date().getTime().toString();
}
if (isUpdateMode.value) {
// Save post title
if (isTitleChanged.value) {
formState.value.post = (
await postUpdateMutate(formState.value.post)
).data;
}
const { data } = await consoleApiClient.content.post.updatePostContent({
name: formState.value.post.metadata.name,
content: formState.value.content,
});
formState.value.post = data;
isTitleChanged.value = false;
} else {
// Clear new post content cache
handleClearCache();
const { data } = await consoleApiClient.content.post.draftPost({
postRequest: formState.value,
});
formState.value.post = data;
name.value = data.metadata.name;
}
if (!options?.mute) {
Toast.success(t("core.common.toast.save_success"));
}
handleClearCache(formState.value.post.metadata.name as string);
await handleFetchContent();
await handleFetchSnapshot();
} catch (e) {
console.error("Failed to save post", e);
Toast.error(t("core.common.toast.save_failed_and_retry"));
} finally {
saving.value = false;
}
};
const returnToView = useRouteQuery<string>("returnToView");
const handlePublish = async () => {
try {
publishing.value = true;
if (isUpdateMode.value) {
const { name: postName } = formState.value.post.metadata;
const { permalink } = formState.value.post.status || {};
if (isTitleChanged.value) {
formState.value.post = (
await postUpdateMutate(formState.value.post)
).data;
}
await consoleApiClient.content.post.updatePostContent({
name: postName,
content: formState.value.content,
});
await consoleApiClient.content.post.publishPost({
name: postName,
});
if (returnToView.value === "true" && permalink) {
window.location.href = permalink;
} else {
router.back();
}
} else {
const { data } = await consoleApiClient.content.post.draftPost({
postRequest: formState.value,
});
await consoleApiClient.content.post.publishPost({
name: data.metadata.name,
});
// Clear new post content cache
handleClearCache();
router.push({ name: "Posts" });
}
Toast.success(t("core.common.toast.publish_success"), {
duration: 2000,
});
handleClearCache(name.value as string);
} catch (error) {
console.error("Failed to publish post", error);
Toast.error(t("core.common.toast.publish_failed_and_retry"));
} finally {
publishing.value = false;
}
};
const handlePublishClick = () => {
if (isUpdateMode.value) {
handlePublish();
} else {
// Set editor title to post
settingModal.value = true;
}
};
const handleFetchContent = async () => {
if (!formState.value.post.spec.headSnapshot) {
return;
}
const { data } = await consoleApiClient.content.post.fetchPostHeadContent({
name: formState.value.post.metadata.name,
});
formState.value.content = Object.assign(formState.value.content, data);
// get editor provider
if (!currentEditorProvider.value) {
const preferredEditor = editorProviders.value.find(
(provider) =>
provider.name ===
formState.value.post.metadata.annotations?.[
contentAnnotations.PREFERRED_EDITOR
]
);
const provider =
preferredEditor ||
editorProviders.value.find(
(provider) =>
provider.rawType.toLowerCase() === data.rawType?.toLowerCase()
);
if (provider) {
currentEditorProvider.value = provider;
formState.value.post.metadata.annotations = {
...formState.value.post.metadata.annotations,
[contentAnnotations.PREFERRED_EDITOR]: provider.name,
};
const { data } = await postUpdateMutate(formState.value.post);
formState.value.post = data;
} else {
Dialog.warning({
title: t("core.common.dialog.titles.warning"),
description: t("core.common.dialog.descriptions.editor_not_found", {
raw_type: data.rawType,
}),
confirmText: t("core.common.buttons.confirm"),
showCancel: false,
onConfirm: () => {
router.back();
},
});
}
await nextTick();
}
};
const handleOpenSettingModal = async () => {
if (isTitleChanged.value) {
await coreApiClient.content.post.patchPost({
name: formState.value.post.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/title",
value:
formState.value.post.spec.title || t("core.post_editor.untitled"),
},
],
});
isTitleChanged.value = false;
}
const { data: latestPost } = await coreApiClient.content.post.getPost({
name: formState.value.post.metadata.name,
});
formState.value.post = latestPost;
settingModal.value = true;
};
// Post settings
const onSettingSaved = (post: Post) => {
// Set route query parameter
if (!isUpdateMode.value) {
name.value = post.metadata.name;
}
formState.value.post = post;
if (!isUpdateMode.value) {
handleSave();
}
};
const onSettingPublished = (post: Post) => {
formState.value.post = post;
handlePublish();
};
// Get post data when the route contains the name parameter
const name = useRouteQuery<string>("name");
onMounted(async () => {
await fetchEditorProviders();
if (name.value) {
// fetch post
const { data: post } = await coreApiClient.content.post.getPost({
name: name.value as string,
});
formState.value.post = post;
// fetch post content
await handleFetchContent();
} else {
// Set default editor
const provider =
editorProviders.value.find(
(provider) => provider.name === storedEditorProviderName.value
) || editorProviders.value[0];
if (provider) {
currentEditorProvider.value = provider;
formState.value.content.rawType = provider.rawType;
}
formState.value.post.metadata.annotations = {
[contentAnnotations.PREFERRED_EDITOR]: provider.name,
};
}
handleResetCache();
});
const headSnapshot = computed(() => {
return formState.value.post.spec.headSnapshot;
});
const { version, handleFetchSnapshot } = useContentSnapshot(headSnapshot);
// Post content cache
const {
currentCache,
handleSetContentCache,
handleResetCache,
handleClearCache,
} = useContentCache(
"post-content-cache",
name,
toRef(formState.value.content, "raw"),
version
);
useAutoSaveContent(currentCache, toRef(formState.value.content, "raw"), () => {
// Do not save when the setting modal is open
if (settingModal.value) {
return;
}
handleSave({ mute: true });
});
// Post preview
const previewModal = ref(false);
const previewPending = ref(false);
const handlePreview = async () => {
previewPending.value = true;
await handleSave({ mute: true });
previewModal.value = true;
previewPending.value = false;
};
useSaveKeybinding(handleSave);
// Keep session alive
useSessionKeepAlive();
// Upload image
async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
if (!currentUserHasPermission(["uc:attachments:manage"])) {
return;
}
const { data } = await ucApiClient.storage.attachment.createAttachmentForPost(
{
file,
postName: formState.value.post.metadata.name,
waitForPermalink: true,
},
options
);
return data;
}
// Slug generation
useSlugify(
computed(() => formState.value.post.spec.title),
computed({
get() {
return formState.value.post.spec.slug;
},
set(value) {
formState.value.post.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value),
FormType.POST
);
</script>
<template>
<PostSettingModal
v-if="settingModal"
:post="formState.post"
:publish-support="!isUpdateMode"
:only-emit="!isUpdateMode"
@close="settingModal = false"
@saved="onSettingSaved"
@published="onSettingPublished"
/>
<UrlPreviewModal
v-if="previewModal"
:title="formState.post.spec.title"
:url="`/preview/posts/${formState.post.metadata.name}`"
@close="previewModal = false"
/>
<VPageHeader :title="$t('core.post.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<EditorProviderSelector
v-if="editorProviders.length > 1"
:provider="currentEditorProvider"
:allow-forced-select="!isUpdateMode"
@select="handleChangeEditorProvider"
/>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="
$router.push({ name: 'PostSnapshots', query: { name: name } })
"
>
<template #icon>
<IconHistoryLine class="h-full w-full" />
</template>
{{ $t("core.post_editor.actions.snapshots") }}
</VButton>
<VButton
size="sm"
type="default"
:loading="previewPending"
@click="handlePreview"
>
<template #icon>
<IconEye class="h-full w-full" />
</template>
{{ $t("core.common.buttons.preview") }}
</VButton>
<VButton :loading="saving" size="sm" type="default" @click="handleSave">
<template #icon>
<IconSave class="h-full w-full" />
</template>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="handleOpenSettingModal"
>
<template #icon>
<IconSettings class="h-full w-full" />
</template>
{{ $t("core.common.buttons.setting") }}
</VButton>
<VButton
type="secondary"
:loading="publishing"
@click="handlePublishClick"
>
<template #icon>
<IconSendPlaneFill class="h-full w-full" />
</template>
{{ $t("core.common.buttons.publish") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
<component
:is="currentEditorProvider.component"
v-if="currentEditorProvider"
v-model:raw="formState.content.raw"
v-model:content="formState.content.content"
v-model:title="formState.post.spec.title"
:upload-image="handleUploadImage"
class="h-full"
@update="handleSetContentCache"
/>
</div>
</template>

View File

@ -0,0 +1,607 @@
<script lang="ts" setup>
import CategoryFilterDropdown from "@/components/filter/CategoryFilterDropdown.vue";
import TagFilterDropdown from "@/components/filter/TagFilterDropdown.vue";
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
import { postLabels } from "@/constants/labels";
import type { ListedPost, Post } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconAddCircle,
IconArrowLeft,
IconArrowRight,
IconBookRead,
IconRefreshLine,
Toast,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import type { Ref } from "vue";
import { computed, provide, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import PostBatchSettingModal from "./components/PostBatchSettingModal.vue";
import PostListItem from "./components/PostListItem.vue";
import PostSettingModal from "./components/PostSettingModal.vue";
const { t } = useI18n();
const settingModal = ref(false);
const selectedPost = ref<Post>();
const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]);
provide<Ref<string[]>>("selectedPostNames", selectedPostNames);
// Filters
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const selectedVisible = useRouteQuery<
"PUBLIC" | "INTERNAL" | "PRIVATE" | undefined
>("visible");
const selectedPublishStatus = useRouteQuery<string | undefined>("status");
const selectedSort = useRouteQuery<string | undefined>("sort");
const selectedCategory = useRouteQuery<string | undefined>("category");
const selectedTag = useRouteQuery<string | undefined>("tag");
const selectedContributor = useRouteQuery<string | undefined>("contributor");
const keyword = useRouteQuery<string>("keyword", "");
const total = ref(0);
const hasPrevious = ref(false);
const hasNext = ref(false);
watch(
() => [
selectedVisible.value,
selectedPublishStatus.value,
selectedSort.value,
selectedCategory.value,
selectedTag.value,
selectedContributor.value,
keyword.value,
],
() => {
page.value = 1;
}
);
function handleClearFilters() {
selectedVisible.value = undefined;
selectedPublishStatus.value = undefined;
selectedSort.value = undefined;
selectedCategory.value = undefined;
selectedTag.value = undefined;
selectedContributor.value = undefined;
}
const hasFilters = computed(() => {
return (
selectedVisible.value ||
selectedPublishStatus.value !== undefined ||
selectedSort.value ||
selectedCategory.value ||
selectedTag.value ||
selectedContributor.value
);
});
const {
data: posts,
isLoading,
isFetching,
refetch,
} = useQuery<ListedPost[]>({
queryKey: [
"posts",
page,
size,
selectedCategory,
selectedTag,
selectedContributor,
selectedPublishStatus,
selectedVisible,
selectedSort,
keyword,
],
queryFn: async () => {
const labelSelector: string[] = ["content.halo.run/deleted=false"];
const fieldSelector: string[] = [];
if (selectedCategory.value) {
fieldSelector.push(`spec.categories=${selectedCategory.value}`);
}
if (selectedTag.value) {
fieldSelector.push(`spec.tags=${selectedTag.value}`);
}
if (selectedContributor.value) {
fieldSelector.push(`status.contributors=${selectedContributor.value}`);
}
if (selectedVisible.value) {
fieldSelector.push(`spec.visible=${selectedVisible.value}`);
}
if (selectedPublishStatus.value !== undefined) {
labelSelector.push(selectedPublishStatus.value);
}
const { data } = await consoleApiClient.content.post.listPosts({
labelSelector,
fieldSelector,
page: page.value,
size: size.value,
sort: [selectedSort.value].filter(Boolean) as string[],
keyword: keyword.value,
});
total.value = data.total;
hasNext.value = data.hasNext;
hasPrevious.value = data.hasPrevious;
return data.items;
},
refetchInterval: (data) => {
const hasDeletingPost = data?.some((post) => post.post.spec.deleted);
if (hasDeletingPost) {
return 1000;
}
const hasPublishingPost = data?.some((post) => {
const { spec, metadata } = post.post;
return (
metadata.labels?.[postLabels.PUBLISHED] !== spec.publish + "" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true"
);
});
if (hasPublishingPost) {
return 1000;
}
const hasCancelingPublishPost = data?.some((post) => {
const { spec, metadata } = post.post;
return (
!spec.publish &&
(metadata.labels?.[postLabels.PUBLISHED] === "true" ||
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] === "true")
);
});
return hasCancelingPublishPost ? 1000 : false;
},
});
const handleOpenSettingModal = async (post: Post) => {
const { data } = await coreApiClient.content.post.getPost({
name: post.metadata.name,
});
selectedPost.value = data;
settingModal.value = true;
};
const onSettingModalClose = () => {
selectedPost.value = undefined;
settingModal.value = false;
refetch();
};
const handleSelectPrevious = async () => {
if (!posts.value) return;
const index = posts.value.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (index > 0) {
const { data: previousPost } = await coreApiClient.content.post.getPost({
name: posts.value[index - 1].post.metadata.name,
});
selectedPost.value = previousPost;
return;
}
if (index === 0 && hasPrevious.value) {
page.value--;
await refetch();
selectedPost.value = posts.value[posts.value.length - 1].post;
}
};
const handleSelectNext = async () => {
if (!posts.value) return;
const index = posts.value.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (index < posts.value.length - 1) {
const { data: nextPost } = await coreApiClient.content.post.getPost({
name: posts.value[index + 1].post.metadata.name,
});
selectedPost.value = nextPost;
return;
}
if (index === posts.value.length - 1 && hasNext) {
page.value++;
await refetch();
selectedPost.value = posts.value[0].post;
}
};
const checkSelection = (post: Post) => {
return (
post.metadata.name === selectedPost.value?.metadata.name ||
selectedPostNames.value.includes(post.metadata.name)
);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPostNames.value =
posts.value?.map((post) => {
return post.post.metadata.name;
}) || [];
} else {
selectedPostNames.value = [];
}
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: t("core.post.operations.delete_in_batch.title"),
description: t("core.post.operations.delete_in_batch.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
return consoleApiClient.content.post.recyclePost({
name,
});
})
);
await refetch();
selectedPostNames.value = [];
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handlePublishInBatch = async () => {
Dialog.info({
title: t("core.post.operations.publish_in_batch.title"),
description: t("core.post.operations.publish_in_batch.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
for (const i in selectedPostNames.value) {
const name = selectedPostNames.value[i];
await consoleApiClient.content.post.publishPost({ name });
}
await refetch();
selectedPostNames.value = [];
Toast.success(t("core.common.toast.publish_success"));
},
});
};
const handleCancelPublishInBatch = async () => {
Dialog.warning({
title: t("core.post.operations.cancel_publish_in_batch.title"),
description: t("core.post.operations.cancel_publish_in_batch.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
for (const i in selectedPostNames.value) {
const name = selectedPostNames.value[i];
await consoleApiClient.content.post.unpublishPost({ name });
}
await refetch();
selectedPostNames.value = [];
Toast.success(t("core.common.toast.cancel_publish_success"));
},
});
};
// Batch settings
const batchSettingModalVisible = ref(false);
const batchSettingPosts = ref<ListedPost[]>([]);
function handleOpenBatchSettingModal() {
batchSettingPosts.value = selectedPostNames.value.map((name) => {
return posts.value?.find((post) => post.post.metadata.name === name);
}) as ListedPost[];
batchSettingModalVisible.value = true;
}
function onBatchSettingModalClose() {
batchSettingModalVisible.value = false;
batchSettingPosts.value = [];
}
watch(
() => selectedPostNames.value,
(newValue) => {
checkedAll.value = newValue.length === posts.value?.length;
}
);
</script>
<template>
<PostSettingModal
v-if="settingModal"
:post="selectedPost"
@close="onSettingModalClose"
>
<template #actions>
<span @click="handleSelectPrevious">
<IconArrowLeft v-tooltip="$t('core.common.buttons.previous')" />
</span>
<span @click="handleSelectNext">
<IconArrowRight v-tooltip="$t('core.common.buttons.next')" />
</span>
</template>
</PostSettingModal>
<PostBatchSettingModal
v-if="batchSettingModalVisible"
:posts="batchSettingPosts"
@close="onBatchSettingModalClose"
/>
<VPageHeader :title="$t('core.post.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'Categories' }" size="sm">
{{ $t("core.post.actions.categories") }}
</VButton>
<VButton :route="{ name: 'Tags' }" size="sm">
{{ $t("core.post.actions.tags") }}
</VButton>
<VButton :route="{ name: 'DeletedPosts' }" size="sm">
{{ $t("core.post.actions.recycle_bin") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
type="secondary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<div
v-permission="['system:posts:manage']"
class="hidden items-center sm:flex"
>
<input
v-model="checkedAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput v-if="!selectedPostNames.length" v-model="keyword" />
<VSpace v-else>
<VButton @click="handlePublishInBatch">
{{ $t("core.common.buttons.publish") }}
</VButton>
<VButton @click="handleCancelPublishInBatch">
{{ $t("core.common.buttons.cancel_publish") }}
</VButton>
<VButton @click="handleOpenBatchSettingModal">
{{ $t("core.post.operations.batch_setting.button") }}
</VButton>
<VButton type="danger" @click="handleDeleteInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div>
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedPublishStatus"
:label="$t('core.common.filters.labels.status')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
value: undefined,
},
{
label: t('core.post.filters.status.items.published'),
value: `${postLabels.PUBLISHED}=true`,
},
{
label: t('core.post.filters.status.items.draft'),
value: `${postLabels.PUBLISHED}=false`,
},
{
label: t('core.post.filters.status.items.scheduling'),
value: `${postLabels.SCHEDULING_PUBLISH}=true`,
},
]"
/>
<FilterDropdown
v-model="selectedVisible"
:label="$t('core.post.filters.visible.label')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
value: undefined,
},
{
label: t('core.post.filters.visible.items.public'),
value: 'PUBLIC',
},
{
label: t('core.post.filters.visible.items.private'),
value: 'PRIVATE',
},
]"
/>
<CategoryFilterDropdown
v-model="selectedCategory"
:label="$t('core.post.filters.category.label')"
/>
<TagFilterDropdown
v-model="selectedTag"
:label="$t('core.post.filters.tag.label')"
/>
<HasPermission :permissions="['system:users:view']">
<UserFilterDropdown
v-model="selectedContributor"
:label="$t('core.post.filters.author.label')"
/>
</HasPermission>
<FilterDropdown
v-model="selectedSort"
:label="$t('core.common.filters.labels.sort')"
:items="[
{
label: t('core.common.filters.item_labels.default'),
},
{
label: t('core.post.filters.sort.items.publish_time_desc'),
value: 'spec.publishTime,desc',
},
{
label: t('core.post.filters.sort.items.publish_time_asc'),
value: 'spec.publishTime,asc',
},
{
label: t('core.post.filters.sort.items.create_time_desc'),
value: 'metadata.creationTimestamp,desc',
},
{
label: t('core.post.filters.sort.items.create_time_asc'),
value: 'metadata.creationTimestamp,asc',
},
{
label: t(
'core.post.filters.sort.items.last_modify_time_desc'
),
value: 'status.lastModifyTime,desc',
},
{
label: t(
'core.post.filters.sort.items.last_modify_time_asc'
),
value: 'status.lastModifyTime,asc',
},
{
label: t('core.post.filters.sort.items.visit_desc'),
value: 'stats.visit,desc',
},
{
label: t('core.post.filters.sort.items.comment_desc'),
value: 'stats.totalComment,desc',
},
]"
/>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!posts?.length" appear name="fade">
<VEmpty
:message="$t('core.post.empty.message')"
:title="$t('core.post.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
type="primary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="post in posts" :key="post.post.metadata.name">
<PostListItem
:post="post"
:is-selected="checkSelection(post.post)"
@open-setting-modal="handleOpenSettingModal"
/>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>

View File

@ -0,0 +1,181 @@
<script setup lang="ts">
import SnapshotContent from "@console/modules/contents/posts/components/SnapshotContent.vue";
import SnapshotListItem from "@console/modules/contents/posts/components/SnapshotListItem.vue";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconHistoryLine,
Toast,
VButton,
VCard,
VLoading,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
const { t } = useI18n();
const route = useRoute();
const queryClient = useQueryClient();
const postName = computed(() => route.query.name as string);
const { data: post } = useQuery({
queryKey: ["post-by-name", postName],
queryFn: async () => {
const { data } = await coreApiClient.content.post.getPost({
name: postName.value,
});
return data;
},
enabled: computed(() => !!postName.value),
});
const { data: snapshots, isLoading } = useQuery({
queryKey: ["post-snapshots-by-post-name", postName],
queryFn: async () => {
const { data } = await consoleApiClient.content.post.listPostSnapshots({
name: postName.value,
});
return data;
},
refetchInterval(data) {
const hasDeletingData = data?.some(
(item) => !!item.metadata.deletionTimestamp
);
return hasDeletingData ? 1000 : false;
},
enabled: computed(() => !!postName.value),
});
const selectedSnapshotName = useRouteQuery<string | undefined>("snapshot-name");
watch(
() => snapshots.value,
(value) => {
if (value && !selectedSnapshotName.value) {
selectedSnapshotName.value = value[0].metadata.name;
}
// Reset selectedSnapshotName if the selected snapshot is deleted
if (
!value?.some(
(snapshot) => snapshot.metadata.name === selectedSnapshotName.value
)
) {
selectedSnapshotName.value = value?.[0].metadata.name;
}
},
{
immediate: true,
}
);
function handleCleanup() {
Dialog.warning({
title: t("core.post_snapshots.operations.cleanup.title"),
description: t("core.post_snapshots.operations.cleanup.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
const { releaseSnapshot, baseSnapshot, headSnapshot } =
post.value?.spec || {};
const snapshotsToDelete = snapshots.value?.filter((snapshot) => {
const { name } = snapshot.metadata;
return ![releaseSnapshot, baseSnapshot, headSnapshot]
.filter(Boolean)
.includes(name);
});
if (!snapshotsToDelete?.length) {
Toast.info(t("core.post_snapshots.operations.cleanup.toast_empty"));
return;
}
for (let i = 0; i < snapshotsToDelete?.length; i++) {
await consoleApiClient.content.post.deletePostContent({
name: postName.value,
snapshotName: snapshotsToDelete[i].metadata.name,
});
}
await queryClient.invalidateQueries({
queryKey: ["post-snapshots-by-post-name", postName],
});
Toast.success(t("core.post_snapshots.operations.cleanup.toast_success"));
},
});
}
</script>
<template>
<VPageHeader :title="post?.spec.title">
<template #icon>
<IconHistoryLine class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton size="sm" @click="$router.back()">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton size="sm" type="danger" @click="handleCleanup">
{{ $t("core.post_snapshots.operations.cleanup.button") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard
style="height: calc(100vh - 5.5rem)"
:body-class="['h-full', '!p-0']"
>
<div class="grid h-full grid-cols-12 divide-y sm:divide-x sm:divide-y-0">
<div
class="relative col-span-12 h-full overflow-auto sm:col-span-6 lg:col-span-3 xl:col-span-2"
>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<VLoading v-if="isLoading" />
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li
v-for="snapshot in snapshots"
:key="snapshot.metadata.name"
@click="selectedSnapshotName = snapshot.metadata.name"
>
<SnapshotListItem
:snapshot="snapshot"
:post="post"
:selected-snapshot-name="selectedSnapshotName"
/>
</li>
</ul>
</Transition>
</OverlayScrollbarsComponent>
</div>
<div
class="col-span-12 h-full overflow-auto sm:col-span-6 lg:col-span-9 xl:col-span-10"
>
<SnapshotContent
:post-name="postName"
:snapshot-name="selectedSnapshotName"
/>
</div>
</div>
</VCard>
</div>
</template>

View File

@ -0,0 +1,143 @@
<script lang="ts" setup>
// core libs
import { coreApiClient } from "@halo-dev/api-client";
import { ref } from "vue";
// components
import {
IconAddCircle,
IconBookRead,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import CategoryEditingModal from "./components/CategoryEditingModal.vue";
import CategoryListItem from "./components/CategoryListItem.vue";
import { convertTreeToCategories, resetCategoriesTreePriority } from "./utils";
// libs
import { useDebounceFn } from "@vueuse/core";
// hooks
import { usePostCategory } from "./composables/use-post-category";
const creationModal = ref(false);
const { categories, categoriesTree, isLoading, handleFetchCategories } =
usePostCategory();
const batchUpdating = ref(false);
const handleUpdateInBatch = useDebounceFn(async () => {
const categoriesTreeToUpdate = resetCategoriesTreePriority(
categoriesTree.value
);
const categoriesToUpdate = convertTreeToCategories(categoriesTreeToUpdate);
try {
batchUpdating.value = true;
const promises = categoriesToUpdate.map((category) =>
coreApiClient.content.category.patchCategory({
name: category.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/children",
value: category.spec.children || [],
},
{
op: "add",
path: "/spec/priority",
value: category.spec.priority || 0,
},
],
})
);
await Promise.all(promises);
} catch (e) {
console.error("Failed to update categories", e);
} finally {
await handleFetchCategories();
batchUpdating.value = false;
}
}, 300);
</script>
<template>
<CategoryEditingModal v-if="creationModal" @close="creationModal = false" />
<VPageHeader :title="$t('core.post_category.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VButton
v-permission="['system:posts:manage']"
type="secondary"
@click="creationModal = true"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium">
{{
$t("core.post_category.header.title", {
count: categories?.length || 0,
})
}}
</span>
</div>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!categories?.length" appear name="fade">
<VEmpty
:message="$t('core.post_category.empty.message')"
:title="$t('core.post_category.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchCategories">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:posts:manage']"
type="primary"
@click="creationModal = true"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<CategoryListItem
v-model="categoriesTree"
:class="{
'cursor-progress opacity-60': batchUpdating,
}"
@change="handleUpdateInBatch"
/>
</Transition>
</VCard>
</div>
</template>

View File

@ -0,0 +1,381 @@
<script lang="ts" setup>
// core libs
import { coreApiClient } from "@halo-dev/api-client";
import { computed, nextTick, onMounted, ref } from "vue";
// components
import SubmitButton from "@/components/button/SubmitButton.vue";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { setFocus } from "@/formkit/utils/focus";
import { FormType } from "@/types/slug";
import useSlugify from "@console/composables/use-slugify";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import { reset, submitForm } from "@formkit/core";
import type { Category } from "@halo-dev/api-client";
import {
IconRefreshLine,
Toast,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { cloneDeep } from "lodash-es";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
category?: Category;
parentCategory?: Category;
isChildLevelCategory: boolean;
}>(),
{
category: undefined,
parentCategory: undefined,
isChildLevelCategory: false,
}
);
const emit = defineEmits<{
(event: "close"): void;
}>();
const queryClient = useQueryClient();
const { t } = useI18n();
const formState = ref<Category>({
spec: {
displayName: "",
slug: "",
description: "",
cover: "",
template: "",
postTemplate: "",
priority: 0,
children: [],
preventParentPostCascadeQuery: false,
},
status: {},
apiVersion: "content.halo.run/v1alpha1",
kind: "Category",
metadata: {
name: "",
generateName: "category-",
},
});
const selectedParentCategory = ref();
const saving = ref(false);
const modal = ref<InstanceType<typeof VModal> | null>(null);
const keepAddingSubmit = ref(false);
const isUpdateMode = !!props.category;
const modalTitle = props.category
? t("core.post_category.editing_modal.titles.update")
: t("core.post_category.editing_modal.titles.create");
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
const handleSaveCategory = async () => {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
annotationsFormRef.value || {};
if (customFormInvalid || specFormInvalid) {
return;
}
formState.value.metadata.annotations = {
...annotations,
...customAnnotations,
};
try {
saving.value = true;
if (isUpdateMode) {
await coreApiClient.content.category.updateCategory({
name: formState.value.metadata.name,
category: formState.value,
});
} else {
// Gets parent category, calculates priority and updates it.
let parentCategory: Category | undefined = undefined;
if (selectedParentCategory.value) {
const { data } = await coreApiClient.content.category.getCategory({
name: selectedParentCategory.value,
});
parentCategory = data;
}
formState.value.spec.priority = parentCategory?.spec.children
? parentCategory.spec.children.length + 1
: 0;
const { data: createdCategory } =
await coreApiClient.content.category.createCategory({
category: formState.value,
});
if (parentCategory) {
await coreApiClient.content.category.patchCategory({
name: selectedParentCategory.value,
jsonPatchInner: [
{
op: "add",
path: "/spec/children",
value: Array.from(
new Set([
...(parentCategory.spec.children || []),
createdCategory.metadata.name,
])
),
},
],
});
}
}
if (keepAddingSubmit.value) {
reset("category-form");
} else {
modal.value?.close();
}
queryClient.invalidateQueries({ queryKey: ["post-categories"] });
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to create category", e);
} finally {
saving.value = false;
}
};
const handleSubmit = (keepAdding = false) => {
keepAddingSubmit.value = keepAdding;
submitForm("category-form");
};
onMounted(() => {
if (props.category) {
formState.value = cloneDeep(props.category);
}
selectedParentCategory.value = props.parentCategory?.metadata.name;
setFocus("displayNameInput");
});
// custom templates
const { templates } = useThemeCustomTemplates("category");
const { templates: postTemplates } = useThemeCustomTemplates("post");
// slug
const { handleGenerateSlug } = useSlugify(
computed(() => formState.value.spec.displayName),
computed({
get() {
return formState.value.spec.slug;
},
set(value) {
formState.value.spec.slug = value;
},
}),
computed(() => !isUpdateMode),
FormType.CATEGORY
);
</script>
<template>
<VModal ref="modal" :title="modalTitle" :width="700" @close="emit('close')">
<FormKit
id="category-form"
type="form"
name="category-form"
:config="{ validationVisibility: 'submit' }"
@submit="handleSaveCategory"
>
<div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post_category.editing_modal.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-if="!isUpdateMode"
v-model="selectedParentCategory"
type="categorySelect"
:label="
$t('core.post_category.editing_modal.fields.parent.label')
"
></FormKit>
<FormKit
id="displayNameInput"
v-model="formState.spec.displayName"
name="displayName"
:label="
$t('core.post_category.editing_modal.fields.display_name.label')
"
type="text"
validation="required|length:0,50"
></FormKit>
<FormKit
v-model="formState.spec.slug"
:help="$t('core.post_category.editing_modal.fields.slug.help')"
name="slug"
:label="$t('core.post_category.editing_modal.fields.slug.label')"
type="text"
validation="required|length:0,50"
>
<template #suffix>
<div
v-tooltip="
$t(
'core.post_category.editing_modal.fields.slug.refresh_message'
)
"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug(true, FormType.CATEGORY)"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
v-model="formState.spec.template"
:options="templates"
:label="
$t('core.post_category.editing_modal.fields.template.label')
"
:help="
$t('core.post_category.editing_modal.fields.template.help')
"
type="select"
name="template"
></FormKit>
<FormKit
v-model="formState.spec.postTemplate"
:options="postTemplates"
:label="
$t(
'core.post_category.editing_modal.fields.post_template.label'
)
"
:help="
$t('core.post_category.editing_modal.fields.post_template.help')
"
type="select"
name="postTemplate"
></FormKit>
<FormKit
v-model="formState.spec.cover"
:help="$t('core.post_category.editing_modal.fields.cover.help')"
name="cover"
:label="$t('core.post_category.editing_modal.fields.cover.label')"
type="attachment"
:accepts="['image/*']"
validation="length:0,1024"
></FormKit>
<FormKit
v-model="formState.spec.hideFromList"
:disabled="isChildLevelCategory"
:label="
$t(
'core.post_category.editing_modal.fields.hide_from_list.label'
)
"
:help="
$t(
'core.post_category.editing_modal.fields.hide_from_list.help'
)
"
type="checkbox"
name="hideFromList"
></FormKit>
<FormKit
v-model="formState.spec.preventParentPostCascadeQuery"
:label="
$t(
'core.post_category.editing_modal.fields.prevent_parent_post_cascade_query.label'
)
"
:help="
$t(
'core.post_category.editing_modal.fields.prevent_parent_post_cascade_query.help'
)
"
type="checkbox"
name="preventParentPostCascadeQuery"
></FormKit>
<FormKit
v-model="formState.spec.description"
name="description"
:help="
$t('core.post_category.editing_modal.fields.description.help')
"
:label="
$t('core.post_category.editing_modal.fields.description.label')
"
type="textarea"
validation="length:0,200"
></FormKit>
</div>
</div>
</div>
</FormKit>
<div class="py-5">
<div class="border-t border-gray-200"></div>
</div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post_category.editing_modal.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<AnnotationsForm
:key="formState.metadata.name"
ref="annotationsFormRef"
:value="formState.metadata.annotations"
kind="Category"
group="content.halo.run"
/>
</div>
</div>
<template #footer>
<div class="flex justify-between">
<VSpace>
<SubmitButton
:loading="saving && !keepAddingSubmit"
:disabled="saving && keepAddingSubmit"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="handleSubmit"
>
</SubmitButton>
<VButton
v-if="!isUpdateMode"
:loading="saving && keepAddingSubmit"
:disabled="saving && !keepAddingSubmit"
@click="handleSubmit(true)"
>
{{ $t("core.common.buttons.save_and_continue") }}
</VButton>
</VSpace>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</div>
</template>
</VModal>
</template>

View File

@ -0,0 +1,206 @@
<script lang="ts" setup>
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { Category } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconEyeOff,
IconList,
Toast,
VDropdownItem,
VEntity,
VEntityField,
VStatusDot,
} from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import type { PropType } from "vue";
import { ref } from "vue";
import { VueDraggable } from "vue-draggable-plus";
import { useI18n } from "vue-i18n";
import GridiconsLinkBreak from "~icons/gridicons/link-break";
import { convertCategoryTreeToCategory, type CategoryTree } from "../utils";
import CategoryEditingModal from "./CategoryEditingModal.vue";
const { currentUserHasPermission } = usePermission();
withDefaults(defineProps<{ isChildLevel?: boolean }>(), {});
const categories = defineModel({
type: Array as PropType<CategoryTree[]>,
default: [],
});
const emit = defineEmits<{
(event: "change"): void;
}>();
const queryClient = useQueryClient();
const { t } = useI18n();
function onChange() {
emit("change");
}
// Editing category
const editingModal = ref(false);
const selectedCategory = ref<Category>();
const selectedParentCategory = ref<Category>();
function onEditingModalClose() {
selectedCategory.value = undefined;
selectedParentCategory.value = undefined;
editingModal.value = false;
}
const handleOpenEditingModal = (category: CategoryTree) => {
selectedCategory.value = convertCategoryTreeToCategory(category);
editingModal.value = true;
};
const handleOpenCreateByParentModal = (category: CategoryTree) => {
selectedParentCategory.value = convertCategoryTreeToCategory(category);
editingModal.value = true;
};
const handleDelete = async (category: CategoryTree) => {
Dialog.warning({
title: t("core.post_category.operations.delete.title"),
description: t("core.post_category.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await coreApiClient.content.category.deleteCategory({
name: category.metadata.name,
});
Toast.success(t("core.common.toast.delete_success"));
queryClient.invalidateQueries({ queryKey: ["post-categories"] });
} catch (e) {
console.error("Failed to delete tag", e);
}
},
});
};
</script>
<template>
<VueDraggable
v-model="categories"
class="box-border h-full w-full divide-y divide-gray-100"
ghost-class="opacity-50"
group="category-item"
handle=".drag-element"
tag="ul"
@sort="onChange"
>
<CategoryEditingModal
v-if="editingModal"
:is-child-level-category="isChildLevel"
:category="selectedCategory"
:parent-category="selectedParentCategory"
@close="onEditingModalClose"
/>
<li v-for="category in categories" :key="category.metadata.name">
<VEntity>
<template #prepend>
<div
v-permission="['system:posts:manage']"
class="drag-element absolute inset-y-0 left-0 hidden w-3.5 cursor-move items-center bg-gray-100 transition-all hover:bg-gray-200 group-hover:flex"
>
<IconList class="h-3.5 w-3.5" />
</div>
</template>
<template #start>
<VEntityField :title="category.spec.displayName">
<template #description>
<a
v-if="category.status?.permalink"
:href="category.status.permalink"
:title="category.status.permalink"
target="_blank"
class="truncate text-xs text-gray-500 group-hover:text-gray-900"
>
{{ category.status.permalink }}
</a>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="category.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField v-if="category.spec.hideFromList">
<template #description>
<IconEyeOff
v-tooltip="$t('core.post_category.list.fields.hide_from_list')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</template>
</VEntityField>
<VEntityField v-if="category.spec.preventParentPostCascadeQuery">
<template #description>
<GridiconsLinkBreak
v-tooltip="
$t(
'core.post_category.list.fields.prevent_parent_post_cascade_query'
)
"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</template>
</VEntityField>
<VEntityField
:description="
$t('core.common.fields.post_count', {
count: category.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(category.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems
>
<VDropdownItem
v-permission="['system:posts:manage']"
@click="handleOpenEditingModal(category)"
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem @click="handleOpenCreateByParentModal(category)">
{{ $t("core.post_category.operations.add_sub_category.button") }}
</VDropdownItem>
<VDropdownItem
v-permission="['system:posts:manage']"
type="danger"
@click="handleDelete(category)"
>
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
<CategoryListItem
v-model="category.spec.children"
is-child-level
class="pl-10 transition-all duration-300"
@change="onChange"
/>
</li>
</VueDraggable>
</template>

View File

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

View File

@ -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<Category[] | undefined>;
categoriesTree: Ref<CategoryTree[]>;
isLoading: Ref<boolean>;
handleFetchCategories: () => void;
}
export function usePostCategory(): usePostCategoryReturn {
const categoriesTree = ref<CategoryTree[]>([] 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,
};
}

View File

@ -0,0 +1,146 @@
import type { Category, CategorySpec } from "@halo-dev/api-client";
import { cloneDeep } from "lodash-es";
export interface CategoryTreeSpec extends Omit<CategorySpec, "children"> {
children: CategoryTree[];
}
export interface CategoryTree extends Omit<Category, "spec"> {
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<string, Category>();
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;
}
}
}
};

View File

@ -0,0 +1,338 @@
<script lang="ts" setup>
import {
coreApiClient,
type JsonPatchInner,
type ListedPost,
} from "@halo-dev/api-client";
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
type ArrayPatchOp = "add" | "replace" | "removeAll";
interface FormData {
category: {
enabled: boolean;
names?: string[];
op: ArrayPatchOp;
};
tag: {
enabled: boolean;
names?: string[];
op: ArrayPatchOp;
};
owner: {
enabled: boolean;
value: string;
};
visible: {
enabled: boolean;
value: "PUBLIC" | "PRIVATE";
};
allowComment: {
enabled: boolean;
value: boolean;
};
}
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(defineProps<{ posts: ListedPost[] }>(), {});
const emit = defineEmits<{
(event: "close"): void;
}>();
const modal = ref<InstanceType<typeof VModal> | null>(null);
const { mutate, isLoading } = useMutation({
mutationKey: ["batch-update-posts"],
mutationFn: async ({ data }: { data: FormData }) => {
for (const key in props.posts) {
const post = props.posts[key];
const jsonPatchInner: JsonPatchInner[] = [];
if (data.category.enabled) {
jsonPatchInner.push({
op: "add",
path: "/spec/categories",
value: computeArrayPatchValue(
data.category.op,
post.post.spec.categories || [],
data.category.names || []
),
});
}
if (data.tag.enabled) {
jsonPatchInner.push({
op: "add",
path: "/spec/tags",
value: computeArrayPatchValue(
data.tag.op,
post.post.spec.tags || [],
data.tag.names || []
),
});
}
if (data.owner.enabled) {
jsonPatchInner.push({
op: "add",
path: "/spec/owner",
value: data.owner.value,
});
}
if (data.visible.enabled) {
jsonPatchInner.push({
op: "add",
path: "/spec/visible",
value: data.visible.value,
});
}
if (data.allowComment.enabled) {
jsonPatchInner.push({
op: "add",
path: "/spec/allowComment",
value: data.allowComment.value,
});
}
await coreApiClient.content.post.patchPost({
name: post.post.metadata.name,
jsonPatchInner,
});
}
Toast.success(t("core.common.toast.save_success"));
},
onSuccess() {
queryClient.invalidateQueries({ queryKey: ["posts"] });
modal.value?.close();
},
onError() {
Toast.error(t("core.common.toast.save_failed_and_retry"));
},
});
function computeArrayPatchValue(
op: ArrayPatchOp,
oldValue: string[],
newValue: string[]
) {
if (op === "add") {
return Array.from(new Set([...oldValue, ...newValue]));
} else if (op === "replace") {
return newValue;
} else if (op === "removeAll") {
return [];
}
}
function onSubmit(data: FormData) {
mutate({ data });
}
</script>
<template>
<VModal
ref="modal"
height="calc(100vh - 20px)"
:title="$t('core.post.batch_setting_modal.title')"
:width="700"
@close="emit('close')"
>
<FormKit
id="post-batch-settings-form"
type="form"
name="post-batch-settings-form"
@submit="onSubmit"
>
<FormKit
v-slot="{ value }"
name="category"
type="group"
:label="$t('core.post.batch_setting_modal.fields.category_group')"
>
<FormKit
:value="false"
:label="$t('core.post.batch_setting_modal.fields.common.enabled')"
type="checkbox"
name="enabled"
></FormKit>
<FormKit
v-if="value?.enabled"
type="select"
:options="[
{
value: 'add',
label: $t(
'core.post.batch_setting_modal.fields.common.op.options.add'
),
},
{
value: 'replace',
label: $t(
'core.post.batch_setting_modal.fields.common.op.options.replace'
),
},
{
value: 'removeAll',
label: $t(
'core.post.batch_setting_modal.fields.common.op.options.remove_all'
),
},
]"
:label="$t('core.post.batch_setting_modal.fields.common.op.label')"
name="op"
value="add"
></FormKit>
<FormKit
v-if="value?.enabled && value?.op !== 'removeAll'"
:label="$t('core.post.batch_setting_modal.fields.category_names')"
type="categorySelect"
:multiple="true"
name="names"
validation="required"
></FormKit>
</FormKit>
<FormKit
v-slot="{ value }"
type="group"
name="tag"
:label="$t('core.post.batch_setting_modal.fields.tag_group')"
>
<FormKit
:value="false"
:label="$t('core.post.batch_setting_modal.fields.common.enabled')"
type="checkbox"
name="enabled"
></FormKit>
<FormKit
v-if="value?.enabled"
type="select"
:options="[
{
value: 'add',
label: $t(
'core.post.batch_setting_modal.fields.common.op.options.add'
),
},
{
value: 'replace',
label: $t(
'core.post.batch_setting_modal.fields.common.op.options.replace'
),
},
{
value: 'removeAll',
label: $t(
'core.post.batch_setting_modal.fields.common.op.options.remove_all'
),
},
]"
:label="$t('core.post.batch_setting_modal.fields.common.op.label')"
name="op"
value="add"
></FormKit>
<FormKit
v-if="value?.enabled && value?.op !== 'removeAll'"
:label="$t('core.post.batch_setting_modal.fields.tag_names')"
type="tagSelect"
:multiple="true"
name="names"
validation="required"
></FormKit>
</FormKit>
<FormKit
v-slot="{ value }"
type="group"
name="owner"
:label="$t('core.post.batch_setting_modal.fields.owner_group')"
>
<FormKit
:value="false"
:label="$t('core.post.batch_setting_modal.fields.common.enabled')"
type="checkbox"
name="enabled"
></FormKit>
<FormKit
v-if="value?.enabled"
:label="$t('core.post.batch_setting_modal.fields.owner_value')"
name="value"
type="userSelect"
></FormKit>
</FormKit>
<FormKit
v-slot="{ value }"
type="group"
name="visible"
:label="$t('core.post.batch_setting_modal.fields.visible_group')"
>
<FormKit
:value="false"
:label="$t('core.post.batch_setting_modal.fields.common.enabled')"
type="checkbox"
name="enabled"
></FormKit>
<FormKit
v-if="value?.enabled"
:options="[
{ label: $t('core.common.select.public'), value: 'PUBLIC' },
{
label: $t('core.common.select.private'),
value: 'PRIVATE',
},
]"
:label="$t('core.post.batch_setting_modal.fields.visible_value')"
name="value"
type="select"
value="PUBLIC"
></FormKit>
</FormKit>
<FormKit
v-slot="{ value }"
type="group"
name="allowComment"
:label="$t('core.post.batch_setting_modal.fields.allow_comment_group')"
>
<FormKit
:value="false"
:label="$t('core.post.batch_setting_modal.fields.common.enabled')"
type="checkbox"
name="enabled"
></FormKit>
<FormKit
v-if="value?.enabled"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
:label="
$t('core.post.batch_setting_modal.fields.allow_comment_value')
"
name="value"
type="radio"
:value="true"
></FormKit>
</FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton
type="secondary"
:loading="isLoading"
@click="$formkit.submit('post-batch-settings-form')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.cancel") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,242 @@
<script lang="ts" setup>
import EntityFieldItems from "@/components/entity-fields/EntityFieldItems.vue";
import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
import { postLabels } from "@/constants/labels";
import { usePermission } from "@/utils/permission";
import { useEntityFieldItemExtensionPoint } from "@console/composables/use-entity-extension-points";
import { useOperationItemExtensionPoint } from "@console/composables/use-operation-extension-points";
import type { ListedPost, Post } from "@halo-dev/api-client";
import { consoleApiClient } from "@halo-dev/api-client";
import {
Dialog,
Toast,
VDropdownDivider,
VDropdownItem,
VEntity,
} from "@halo-dev/components";
import type { EntityFieldItem, OperationItem } from "@halo-dev/console-shared";
import { useQueryClient } from "@tanstack/vue-query";
import type { Ref } from "vue";
import { computed, inject, markRaw, ref, toRefs } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import ContributorsField from "./entity-fields/ContributorsField.vue";
import PublishStatusField from "./entity-fields/PublishStatusField.vue";
import PublishTimeField from "./entity-fields/PublishTimeField.vue";
import TitleField from "./entity-fields/TitleField.vue";
import VisibleField from "./entity-fields/VisibleField.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const queryClient = useQueryClient();
const router = useRouter();
const props = withDefaults(
defineProps<{
post: ListedPost;
isSelected?: boolean;
}>(),
{
isSelected: false,
}
);
const { post } = toRefs(props);
const emit = defineEmits<{
(event: "open-setting-modal", post: Post): void;
}>();
const selectedPostNames = inject<Ref<string[]>>("selectedPostNames", ref([]));
const handleDelete = async () => {
Dialog.warning({
title: t("core.post.operations.delete.title"),
description: t("core.post.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await consoleApiClient.content.post.recyclePost({
name: props.post.post.metadata.name,
});
await queryClient.invalidateQueries({ queryKey: ["posts"] });
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const { operationItems } = useOperationItemExtensionPoint<ListedPost>(
"post:list-item:operation:create",
post,
computed((): OperationItem<ListedPost>[] => [
{
priority: 0,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.publish"),
action: async () => {
await consoleApiClient.content.post.publishPost({
name: props.post.post.metadata.name,
});
Toast.success(t("core.common.toast.publish_success"));
queryClient.invalidateQueries({
queryKey: ["posts"],
});
},
hidden:
props.post.post.metadata.labels?.[postLabels.PUBLISHED] == "true" ||
props.post.post.metadata.labels?.[postLabels.SCHEDULING_PUBLISH] ==
"true",
},
{
priority: 10,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.edit"),
permissions: [],
action: () => {
router.push({
name: "PostEditor",
query: { name: props.post.post.metadata.name },
});
},
},
{
priority: 20,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.setting"),
permissions: [],
action: () => {
emit("open-setting-modal", props.post.post);
},
},
{
priority: 30,
component: markRaw(VDropdownDivider),
},
{
priority: 40,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.cancel_publish"),
action: async () => {
await consoleApiClient.content.post.unpublishPost({
name: props.post.post.metadata.name,
});
Toast.success(t("core.common.toast.cancel_publish_success"));
queryClient.invalidateQueries({
queryKey: ["posts"],
});
},
hidden:
props.post.post.metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
props.post.post.metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !==
"true",
},
{
priority: 50,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.delete"),
permissions: [],
action: handleDelete,
},
])
);
const { startFields, endFields } = useEntityFieldItemExtensionPoint<ListedPost>(
"post:list-item:field:create",
post,
computed((): EntityFieldItem[] => [
{
priority: 10,
position: "start",
component: markRaw(TitleField),
props: {
post: props.post,
},
},
{
priority: 10,
position: "end",
component: markRaw(ContributorsField),
props: {
post: props.post,
},
},
{
priority: 20,
position: "end",
component: markRaw(PublishStatusField),
props: {
post: props.post,
},
},
{
priority: 30,
position: "end",
component: markRaw(VisibleField),
permissions: ["system:posts:manage"],
props: {
post: props.post,
},
},
{
priority: 40,
position: "end",
component: markRaw(StatusDotField),
props: {
tooltip: t("core.common.status.deleting"),
state: "warning",
animate: true,
},
hidden: !props.post.post.spec.deleted,
},
{
priority: 50,
position: "end",
component: markRaw(PublishTimeField),
props: {
post: props.post,
},
},
])
);
</script>
<template>
<VEntity :is-selected="isSelected">
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#checkbox
>
<input
v-model="selectedPostNames"
:value="post.post.metadata.name"
name="post-checkbox"
type="checkbox"
/>
</template>
<template #start>
<EntityFieldItems :fields="startFields" />
</template>
<template #end>
<EntityFieldItems :fields="endFields" />
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems
>
<EntityDropdownItems :dropdown-items="operationItems" :item="post" />
</template>
</VEntity>
</template>

View File

@ -0,0 +1,520 @@
<script lang="ts" setup>
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { postLabels } from "@/constants/labels";
import { FormType } from "@/types/slug";
import { formatDatetime, toDatetimeLocal, toISOString } from "@/utils/date";
import { randomUUID } from "@/utils/id";
import useSlugify from "@console/composables/use-slugify";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import { submitForm } from "@formkit/core";
import type { Post } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
IconRefreshLine,
Toast,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
import { cloneDeep } from "lodash-es";
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { usePostUpdateMutate } from "../composables/use-post-update-mutate";
const props = withDefaults(
defineProps<{
post?: Post;
publishSupport?: boolean;
onlyEmit?: boolean;
}>(),
{
post: undefined,
publishSupport: true,
onlyEmit: false,
}
);
const emit = defineEmits<{
(event: "close"): void;
(event: "saved", post: Post): void;
(event: "published", post: Post): void;
}>();
const { t } = useI18n();
const modal = ref<InstanceType<typeof VModal>>();
const formState = ref<Post>({
spec: {
title: "",
slug: "",
template: "",
cover: "",
deleted: false,
publish: false,
publishTime: undefined,
pinned: false,
allowComment: true,
visible: "PUBLIC",
priority: 0,
excerpt: {
autoGenerate: true,
raw: "",
},
categories: [],
tags: [],
htmlMetas: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Post",
metadata: {
name: randomUUID(),
},
});
const isSubmitting = ref(false);
const publishing = ref(false);
const publishCanceling = ref(false);
const submitType = ref<"publish" | "save">();
const publishTime = ref<string | undefined>(undefined);
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const handleSubmit = () => {
if (submitType.value === "publish") {
handlePublish();
}
if (submitType.value === "save") {
handleSave();
}
};
const handleSaveClick = () => {
submitType.value = "save";
nextTick(() => {
submitForm("post-setting-form");
});
};
const handlePublishClick = () => {
submitType.value = "publish";
nextTick(() => {
submitForm("post-setting-form");
});
};
// Fix me:
// Force update post settings,
// because currently there may be errors caused by changes in version due to asynchronous processing.
const { mutateAsync: postUpdateMutate } = usePostUpdateMutate();
const handleSave = async () => {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
annotationsFormRef.value || {};
if (customFormInvalid || specFormInvalid) {
return;
}
formState.value.metadata.annotations = {
...annotations,
...customAnnotations,
};
if (props.onlyEmit) {
emit("saved", formState.value);
modal.value?.close();
return;
}
try {
isSubmitting.value = true;
const { data } = isUpdateMode.value
? await postUpdateMutate(formState.value)
: await coreApiClient.content.post.createPost({
post: formState.value,
});
formState.value = data;
emit("saved", data);
modal.value?.close();
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to save post", e);
} finally {
isSubmitting.value = false;
}
};
const handlePublish = async () => {
if (props.onlyEmit) {
emit("published", formState.value);
modal.value?.close();
return;
}
try {
publishing.value = true;
await postUpdateMutate(formState.value);
const { data } = await consoleApiClient.content.post.publishPost({
name: formState.value.metadata.name,
});
formState.value = data;
emit("published", data);
modal.value?.close();
Toast.success(t("core.common.toast.publish_success"));
} catch (e) {
console.error("Failed to publish post", e);
} finally {
publishing.value = false;
}
};
const handleUnpublish = async () => {
try {
publishCanceling.value = true;
await consoleApiClient.content.post.unpublishPost({
name: formState.value.metadata.name,
});
modal.value?.close();
Toast.success(t("core.common.toast.cancel_publish_success"));
} catch (e) {
console.error("Failed to publish post", e);
} finally {
publishCanceling.value = false;
}
};
// publish time
watch(
() => props.post,
(value) => {
if (value) {
formState.value = cloneDeep(value);
publishTime.value = toDatetimeLocal(formState.value.spec.publishTime);
}
},
{
immediate: true,
}
);
watch(
() => publishTime.value,
(value) => {
formState.value.spec.publishTime = value ? toISOString(value) : undefined;
}
);
const isScheduledPublish = computed(() => {
return (
formState.value.spec.publishTime &&
new Date(formState.value.spec.publishTime) > new Date()
);
});
const publishTimeHelp = computed(() => {
return isScheduledPublish.value
? t("core.post.settings.fields.publish_time.help.schedule_publish", {
datetime: formatDatetime(publishTime.value),
})
: "";
});
// custom templates
const { templates } = useThemeCustomTemplates("post");
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
// slug
const { handleGenerateSlug } = useSlugify(
computed(() => formState.value.spec.title),
computed({
get() {
return formState.value.spec.slug;
},
set(value) {
formState.value.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value),
FormType.POST
);
// Buttons condition
const showPublishButton = computed(() => {
if (!props.publishSupport) {
return false;
}
const {
[postLabels.PUBLISHED]: published,
[postLabels.SCHEDULING_PUBLISH]: schedulingPublish,
} = formState.value.metadata.labels || {};
return published !== "true" && schedulingPublish !== "true";
});
const showCancelPublishButton = computed(() => {
const {
[postLabels.PUBLISHED]: published,
[postLabels.SCHEDULING_PUBLISH]: schedulingPublish,
} = formState.value.metadata.labels || {};
return published === "true" || schedulingPublish === "true";
});
</script>
<template>
<VModal
ref="modal"
:width="700"
:title="$t('core.post.settings.title')"
:centered="false"
@close="emit('close')"
>
<template #actions>
<slot name="actions"></slot>
</template>
<FormKit
id="post-setting-form"
type="form"
name="post-setting-form"
:config="{ validationVisibility: 'submit' }"
@submit="handleSubmit"
>
<div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post.settings.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-model="formState.spec.title"
:label="$t('core.post.settings.fields.title.label')"
type="text"
name="title"
validation="required|length:0,100"
></FormKit>
<FormKit
v-model="formState.spec.slug"
:label="$t('core.post.settings.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,100"
:help="$t('core.post.settings.fields.slug.help')"
>
<template #suffix>
<div
v-tooltip="
$t('core.post.settings.fields.slug.refresh_message')
"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug(true, FormType.POST)"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
v-model="formState.spec.categories"
:label="$t('core.post.settings.fields.categories.label')"
name="categories"
type="categorySelect"
:multiple="true"
/>
<FormKit
v-model="formState.spec.tags"
:label="$t('core.post.settings.fields.tags.label')"
name="tags"
type="tagSelect"
:multiple="true"
/>
<FormKit
v-model="formState.spec.excerpt.autoGenerate"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
name="autoGenerate"
:label="
$t('core.post.settings.fields.auto_generate_excerpt.label')
"
type="radio"
>
</FormKit>
<FormKit
v-if="!formState.spec.excerpt.autoGenerate"
v-model="formState.spec.excerpt.raw"
:label="$t('core.post.settings.fields.raw_excerpt.label')"
name="raw"
type="textarea"
:rows="5"
validation="length:0,1024"
></FormKit>
</div>
</div>
<div class="py-5">
<div class="border-t border-gray-200"></div>
</div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post.settings.groups.advanced") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-model="formState.spec.owner"
:label="$t('core.post.settings.fields.owner.label')"
type="userSelect"
></FormKit>
<FormKit
v-model="formState.spec.allowComment"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
:label="$t('core.post.settings.fields.allow_comment.label')"
type="radio"
></FormKit>
<FormKit
v-model="formState.spec.pinned"
:options="[
{ label: $t('core.common.radio.yes'), value: true },
{ label: $t('core.common.radio.no'), value: false },
]"
:label="$t('core.post.settings.fields.pinned.label')"
name="pinned"
type="radio"
></FormKit>
<FormKit
v-model="formState.spec.visible"
:options="[
{ label: $t('core.common.select.public'), value: 'PUBLIC' },
{
label: $t('core.common.select.private'),
value: 'PRIVATE',
},
]"
:label="$t('core.post.settings.fields.visible.label')"
name="visible"
type="select"
></FormKit>
<FormKit
v-model="publishTime"
:label="$t('core.post.settings.fields.publish_time.label')"
type="datetime-local"
min="0000-01-01T00:00"
max="9999-12-31T23:59"
:help="publishTimeHelp"
></FormKit>
<FormKit
v-model="formState.spec.template"
:options="templates"
:label="$t('core.post.settings.fields.template.label')"
name="template"
type="select"
></FormKit>
<FormKit
v-model="formState.spec.cover"
name="cover"
:label="$t('core.post.settings.fields.cover.label')"
type="attachment"
:accepts="['image/*']"
validation="length:0,1024"
></FormKit>
</div>
</div>
</div>
</FormKit>
<div class="py-5">
<div class="border-t border-gray-200"></div>
</div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post.settings.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<AnnotationsForm
:key="formState.metadata.name"
ref="annotationsFormRef"
:value="formState.metadata.annotations"
kind="Post"
group="content.halo.run"
/>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between">
<VSpace>
<VButton
v-if="showPublishButton"
:loading="publishing"
type="secondary"
@click="handlePublishClick()"
>
{{
isScheduledPublish
? $t("core.common.buttons.schedule_publish")
: $t("core.common.buttons.publish")
}}
</VButton>
<VButton
:loading="isSubmitting"
type="secondary"
@click="handleSaveClick()"
>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton type="default" @click="modal?.close()">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
<VButton
v-if="showCancelPublishButton"
:loading="publishCanceling"
type="danger"
@click="handleUnpublish()"
>
{{ $t("core.common.buttons.cancel_publish") }}
</VButton>
</div>
</template>
</VModal>
</template>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { consoleApiClient } from "@halo-dev/api-client";
import { Toast, VLoading } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { computed, toRefs } from "vue";
const props = withDefaults(
defineProps<{
postName?: string;
snapshotName?: string;
}>(),
{
postName: undefined,
snapshotName: undefined,
}
);
const { postName, snapshotName } = toRefs(props);
const { data: snapshot, isLoading } = useQuery({
queryKey: ["post-snapshot-by-name", postName, snapshotName],
queryFn: async () => {
if (!postName.value || !snapshotName.value) {
throw new Error("postName and snapshotName are required");
}
const { data } = await consoleApiClient.content.post.fetchPostContent({
name: postName.value,
snapshotName: snapshotName.value,
});
return data;
},
onError(err) {
if (err instanceof Error) {
Toast.error(err.message);
}
},
enabled: computed(() => !!postName.value && !!snapshotName.value),
});
</script>
<template>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<VLoading v-if="isLoading" />
<div
v-else
class="snapshot-content markdown-body h-full w-full p-4"
v-html="snapshot?.content"
></div>
</OverlayScrollbarsComponent>
</template>
<style scoped lang="scss">
::v-deep(.snapshot-content) {
p {
margin-top: 0.75em;
margin-bottom: 0;
}
pre {
background: #0d0d0d;
padding: 0.75rem 1rem;
margin: 0;
code {
color: #ccc;
background: none;
font-size: 0.8rem;
padding: 0 !important;
border-radius: 0;
}
}
ul[data-type="taskList"] {
list-style: none;
padding: 0;
p {
margin: 0;
}
li {
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
}
}
ul {
list-style: disc !important;
}
ol {
list-style: decimal !important;
}
code br {
display: initial;
}
}
</style>

View File

@ -0,0 +1,138 @@
<script setup lang="ts">
import { relativeTimeTo } from "@/utils/date";
import type { ListedSnapshotDto, Post } from "@halo-dev/api-client";
import { consoleApiClient } from "@halo-dev/api-client";
import { Dialog, Toast, VButton, VStatusDot, VTag } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
post?: Post;
snapshot: ListedSnapshotDto;
selectedSnapshotName?: string;
}>(),
{
post: undefined,
selectedSnapshotName: undefined,
}
);
async function handleRestore() {
Dialog.warning({
title: t("core.post_snapshots.operations.revert.title"),
description: t("core.post_snapshots.operations.revert.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await consoleApiClient.content.post.revertToSpecifiedSnapshotForPost({
name: props.post?.metadata.name as string,
revertSnapshotForPostParam: {
snapshotName: props.snapshot.metadata.name,
},
});
await queryClient.invalidateQueries({
queryKey: ["post-snapshots-by-post-name"],
});
Toast.success(t("core.post_snapshots.operations.revert.toast_success"));
},
});
}
function handleDelete() {
Dialog.warning({
title: t("core.post_snapshots.operations.delete.title"),
description: t("core.post_snapshots.operations.delete.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await consoleApiClient.content.post.deletePostContent({
name: props.post?.metadata.name as string,
snapshotName: props.snapshot.metadata.name,
});
await queryClient.invalidateQueries({
queryKey: ["post-snapshots-by-post-name"],
});
Toast.success(t("core.common.toast.delete_success"));
},
});
}
const isSelected = computed(() => {
return props.selectedSnapshotName === props.snapshot.metadata.name;
});
const isReleased = computed(() => {
return props.post?.spec.releaseSnapshot === props.snapshot.metadata.name;
});
const isHead = computed(() => {
const { headSnapshot, releaseSnapshot } = props.post?.spec || {};
return (
headSnapshot !== releaseSnapshot &&
headSnapshot === props.snapshot.metadata.name
);
});
const isBase = computed(() => {
return props.post?.spec.baseSnapshot === props.snapshot.metadata.name;
});
</script>
<template>
<div
class="group relative flex cursor-pointer flex-col gap-5 p-4"
:class="{ 'bg-gray-50': isSelected }"
>
<div
v-if="isSelected"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="flex items-center justify-between">
<div
class="truncate text-sm"
:class="{
'font-semibold': isSelected,
}"
>
{{ relativeTimeTo(snapshot.metadata.creationTimestamp) }}
</div>
<div class="inline-flex flex-none items-center space-x-3">
<VTag v-if="isReleased" theme="primary">
{{ $t("core.post_snapshots.status.released") }}
</VTag>
<VTag v-if="isHead">
{{ $t("core.post_snapshots.status.draft") }}
</VTag>
<VTag v-if="isBase">
{{ $t("core.post_snapshots.status.base") }}
</VTag>
<VStatusDot
v-if="snapshot.metadata.deletionTimestamp"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</div>
</div>
<div class="flex h-6 items-end justify-between gap-2">
<div class="flex-1 truncate text-xs text-gray-600">
{{ snapshot.spec.owner }}
</div>
<div
v-if="!isReleased"
class="hidden flex-none space-x-2 group-hover:block"
>
<VButton v-if="!isHead" size="xs" @click="handleRestore()">
{{ $t("core.post_snapshots.operations.revert.button") }}
</VButton>
<VButton v-if="!isBase" size="xs" type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VButton>
</div>
</div>
</div>
</template>

View File

@ -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: `<PostSettingModal></PostSettingModal>`,
},
{
global: {
plugins: [
VueQueryPlugin,
createI18n({
legacy: false,
locale: "en",
messages,
}),
],
},
}
);
expect(wrapper).toBeDefined();
});
});

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import type { ListedPost } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components";
withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
</script>
<template>
<VEntityField>
<template #description>
<PostContributorList
:owner="post.owner"
:contributors="post.contributors"
/>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,40 @@
<script lang="ts" setup>
import { postLabels } from "@/constants/labels";
import type { ListedPost } from "@halo-dev/api-client";
import { VEntityField, VStatusDot } from "@halo-dev/components";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
const publishStatus = computed(() => {
const { labels } = props.post.post.metadata;
return labels?.[postLabels.PUBLISHED] === "true"
? t("core.post.filters.status.items.published")
: t("core.post.filters.status.items.draft");
});
const isPublishing = computed(() => {
const { spec, metadata } = props.post.post;
return (
spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true"
);
});
</script>
<template>
<VEntityField :description="publishStatus">
<template v-if="isPublishing" #description>
<VStatusDot :text="$t('core.common.tooltips.publishing')" animate />
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,33 @@
<script lang="ts" setup>
import { postLabels } from "@/constants/labels";
import { formatDatetime } from "@/utils/date";
import type { ListedPost } from "@halo-dev/api-client";
import { IconTimerLine, VEntityField } from "@halo-dev/components";
withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
</script>
<template>
<VEntityField>
<template #description>
<div class="inline-flex items-center space-x-2">
<span class="entity-field-description">
{{ formatDatetime(post.post.spec.publishTime) }}
</span>
<IconTimerLine
v-if="
post.post.metadata.labels?.[postLabels.SCHEDULING_PUBLISH] ===
'true'
"
v-tooltip="$t('core.post.list.fields.schedule_publish.tooltip')"
class="text-sm"
/>
</div>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,108 @@
<script lang="ts" setup>
import { postLabels } from "@/constants/labels";
import type { ListedPost } from "@halo-dev/api-client";
import {
IconExternalLinkLine,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { computed } from "vue";
import PostTag from "../../tags/components/PostTag.vue";
const props = withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
const externalUrl = computed(() => {
const { status, metadata } = props.post.post;
if (metadata.labels?.[postLabels.PUBLISHED] === "true") {
return status?.permalink;
}
return `/preview/posts/${metadata.name}`;
});
</script>
<template>
<VEntityField
:title="post.post.spec.title"
:route="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
width="27rem"
>
<template #extra>
<VSpace class="mt-1 sm:mt-0">
<RouterLink
v-if="post.post.status?.inProgress"
v-tooltip="$t('core.common.tooltips.unpublished_content_tip')"
:to="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
class="flex items-center"
>
<VStatusDot state="success" animate />
</RouterLink>
<a
target="_blank"
:href="externalUrl"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</VSpace>
</template>
<template #description>
<div class="flex flex-col gap-1.5">
<VSpace class="flex-wrap !gap-y-1">
<p
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
{{ $t("core.post.list.fields.categories") }}
<a
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
:href="category.status?.permalink"
:title="category.status?.permalink"
target="_blank"
class="cursor-pointer hover:text-gray-900"
>
{{ category.spec.displayName }}
</a>
</p>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.visits", {
visits: post.stats.visit,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.comments", {
comments: post.stats.totalComment || 0,
})
}}
</span>
<span v-if="post.post.spec.pinned" class="text-xs text-gray-500">
{{ $t("core.post.list.fields.pinned") }}
</span>
</VSpace>
<VSpace v-if="post.tags.length" class="flex-wrap">
<PostTag
v-for="(tag, tagIndex) in post.tags"
:key="tagIndex"
:tag="tag"
route
></PostTag>
</VSpace>
</div>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,59 @@
<script lang="ts" setup>
import type { ListedPost, Post } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import { IconEye, IconEyeOff, Toast, VEntityField } from "@halo-dev/components";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const queryClient = useQueryClient();
const { t } = useI18n();
withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
const { mutate: changeVisibleMutation } = useMutation({
mutationFn: async (post: Post) => {
return await coreApiClient.content.post.patchPost({
name: post.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/visible",
value: post.spec.visible === "PRIVATE" ? "PUBLIC" : "PRIVATE",
},
],
});
},
retry: 3,
onSuccess: () => {
Toast.success(t("core.common.toast.operation_success"));
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
onError: () => {
Toast.error(t("core.common.toast.operation_failed"));
},
});
</script>
<template>
<VEntityField>
<template #description>
<IconEye
v-if="post.post.spec.visible === 'PUBLIC'"
v-tooltip="$t('core.post.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(post.post)"
/>
<IconEyeOff
v-if="post.post.spec.visible === 'PRIVATE'"
v-tooltip="$t('core.post.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(post.post)"
/>
</template>
</VEntityField>
</template>

View File

@ -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"));
},
});
}

View File

@ -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"],
},
},
],
},
],
},
],
});

View File

@ -0,0 +1,319 @@
<script lang="ts" setup>
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import SearchInput from "@/components/input/SearchInput.vue";
import HasPermission from "@/components/permission/HasPermission.vue";
import type { Tag } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
IconAddCircle,
IconBookRead,
IconRefreshLine,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
} from "@halo-dev/components";
import { useRouteQuery } from "@vueuse/router";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import TagEditingModal from "./components/TagEditingModal.vue";
import TagListItem from "./components/TagListItem.vue";
import { usePostTag } from "./composables/use-post-tag";
const { t } = useI18n();
const editingModal = ref(false);
const selectedTag = ref<Tag>();
const selectedTagNames = ref<string[]>([]);
const checkedAll = ref(false);
const keyword = useRouteQuery<string>("keyword", "");
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const selectedSort = useRouteQuery<string | undefined>("sort");
const hasFilters = computed(() => {
return !!selectedSort.value;
});
const handleClearFilters = () => {
selectedSort.value = undefined;
};
const {
tags,
total,
hasNext,
hasPrevious,
isLoading,
isFetching,
handleFetchTags,
handleDelete,
handleDeleteInBatch,
} = usePostTag({
page,
size,
keyword,
sort: selectedSort,
});
const handleOpenEditingModal = (tag?: Tag) => {
selectedTag.value = tag;
editingModal.value = true;
};
const handleDeleteTagInBatch = () => {
handleDeleteInBatch(selectedTagNames.value).then(() => {
selectedTagNames.value = [];
});
};
const handleCheckAllChange = () => {
if (checkedAll.value) {
selectedTagNames.value = tags.value?.map((tag) => tag.metadata.name) || [];
} else {
selectedTagNames.value = [];
}
};
const handleSelectPrevious = async () => {
if (!tags.value) return;
const currentIndex = tags.value.findIndex(
(tag) => tag.metadata.name === selectedTag.value?.metadata.name
);
if (currentIndex > 0) {
selectedTag.value = tags.value[currentIndex - 1];
return;
}
if (currentIndex === 0 && hasPrevious.value) {
page.value--;
await handleFetchTags();
setTimeout(() => {
if (!tags.value) return;
selectedTag.value = tags.value[tags.value.length - 1];
});
}
};
const handleSelectNext = async () => {
if (!tags.value) return;
if (!selectedTag.value) {
selectedTag.value = tags.value[0];
return;
}
const currentIndex = tags.value.findIndex(
(tag) => tag.metadata.name === selectedTag.value?.metadata.name
);
if (currentIndex !== tags.value.length - 1) {
selectedTag.value = tags.value[currentIndex + 1];
}
if (currentIndex === tags.value.length - 1 && hasNext.value) {
page.value++;
await handleFetchTags();
setTimeout(() => {
if (!tags.value) return;
selectedTag.value = tags.value[0];
});
}
};
const onEditingModalClose = () => {
selectedTag.value = undefined;
queryName.value = null;
editingModal.value = false;
handleFetchTags();
};
const queryName = useRouteQuery("name");
onMounted(async () => {
if (queryName.value) {
const { data } = await coreApiClient.content.tag.getTag({
name: queryName.value as string,
});
selectedTag.value = data;
editingModal.value = true;
}
});
watch(selectedTagNames, (newVal) => {
checkedAll.value = newVal.length === tags.value?.length;
});
</script>
<template>
<TagEditingModal
v-if="editingModal"
:tag="selectedTag"
@close="onEditingModalClose"
@next="handleSelectNext"
@previous="handleSelectPrevious"
/>
<VPageHeader :title="$t('core.post_tag.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VButton
v-permission="['system:posts:manage']"
type="secondary"
@click="editingModal = true"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex h-9 flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<HasPermission :permissions="['system:posts:manage']">
<div class="hidden items-center sm:flex">
<input
v-model="checkedAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
</HasPermission>
<div class="flex w-full flex-1 items-center sm:w-auto">
<VSpace v-if="selectedTagNames.length > 0">
<VButton type="danger" @click="handleDeleteTagInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
<SearchInput v-else v-model="keyword" />
</div>
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedSort"
:label="$t('core.common.filters.labels.sort')"
:items="[
{
label: t('core.common.filters.item_labels.default'),
},
{
label: t(
'core.post.tag.filters.sort.items.create_time_desc'
),
value: 'metadata.creationTimestamp,desc',
},
{
label: t(
'core.post.tag.filters.sort.items.create_time_asc'
),
value: 'metadata.creationTimestamp,asc',
},
{
label: t(
'core.post.tag.filters.sort.items.display_name_desc'
),
value: 'spec.displayName,desc',
},
{
label: t(
'core.post.tag.filters.sort.items.display_name_asc'
),
value: 'spec.displayName,asc',
},
]"
/>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="handleFetchTags()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!tags?.length" appear name="fade">
<VEmpty
:message="$t('core.post_tag.empty.message')"
:title="$t('core.post_tag.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="() => handleFetchTags">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="primary" @click="editingModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(tag, index) in tags" :key="index">
<TagListItem
:tag="tag"
:is-selected="selectedTag?.metadata.name === tag.metadata.name"
@editing="handleOpenEditingModal"
@delete="handleDelete"
>
<template #checkbox>
<input
v-model="selectedTagNames"
:value="tag.metadata.name"
type="checkbox"
/>
</template>
</TagListItem>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>

View File

@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { Tag } from "@halo-dev/api-client";
import { VTag } from "@halo-dev/components";
import Color from "colorjs.io";
import { computed } from "vue";
import { useRouter } from "vue-router";
const props = withDefaults(
defineProps<{
tag: Tag;
rounded?: boolean;
route?: boolean;
}>(),
{
rounded: false,
route: false,
}
);
const labelColor = computed(() => {
const { color } = props.tag.spec;
if (!color) {
return "inherit";
}
const onWhite = Math.abs(Color.contrast(color, "white", "APCA"));
const onBlack = Math.abs(Color.contrast(color, "black", "APCA"));
return onWhite > onBlack ? "white" : "#333";
});
const router = useRouter();
const handleRouteToDetail = () => {
if (!props.route) {
return;
}
router.push({
name: "Tags",
query: { name: props.tag.metadata.name },
});
};
</script>
<template>
<VTag
:styles="{
background: tag.spec.color,
color: labelColor,
}"
:rounded="rounded"
@click="handleRouteToDetail"
>
{{ tag.spec.displayName }}
<template #rightIcon>
<slot name="rightIcon" />
</template>
</VTag>
</template>

View File

@ -0,0 +1,290 @@
<script lang="ts" setup>
// core libs
import { coreApiClient } from "@halo-dev/api-client";
import { computed, nextTick, ref, watch } from "vue";
// components
import SubmitButton from "@/components/button/SubmitButton.vue";
import {
IconArrowLeft,
IconArrowRight,
IconRefreshLine,
Toast,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
// types
import type { Tag } from "@halo-dev/api-client";
// libs
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { setFocus } from "@/formkit/utils/focus";
import { FormType } from "@/types/slug";
import useSlugify from "@console/composables/use-slugify";
import { cloneDeep } from "lodash-es";
import { onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { submitForm, reset } from "@formkit/core";
const props = withDefaults(
defineProps<{
tag?: Tag;
}>(),
{
tag: undefined,
}
);
const emit = defineEmits<{
(event: "close"): void;
(event: "previous"): void;
(event: "next"): void;
}>();
const { t } = useI18n();
const formState = ref<Tag>({
spec: {
displayName: "",
slug: "",
color: "#ffffff",
cover: "",
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Tag",
metadata: {
name: "",
generateName: "tag-",
},
});
const modal = ref<InstanceType<typeof VModal> | null>(null);
const saving = ref(false);
const keepAddingSubmit = ref(false);
const isUpdateMode = computed(() => !!props.tag);
const modalTitle = computed(() => {
return isUpdateMode.value
? t("core.post_tag.editing_modal.titles.update")
: t("core.post_tag.editing_modal.titles.create");
});
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
const handleSaveTag = async () => {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
annotationsFormRef.value || {};
if (customFormInvalid || specFormInvalid) {
return;
}
formState.value.metadata.annotations = {
...annotations,
...customAnnotations,
};
try {
saving.value = true;
if (isUpdateMode.value) {
await coreApiClient.content.tag.updateTag({
name: formState.value.metadata.name,
tag: formState.value,
});
} else {
await coreApiClient.content.tag.createTag({
tag: formState.value,
});
}
if (keepAddingSubmit.value) {
reset("tag-form");
} else {
modal.value?.close();
}
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to create tag", e);
} finally {
saving.value = false;
}
};
const handleSubmit = (keepAdding = false) => {
keepAddingSubmit.value = keepAdding;
submitForm("tag-form");
};
onMounted(() => {
setFocus("displayNameInput");
});
watch(
() => props.tag,
(tag) => {
if (tag) {
formState.value = cloneDeep(tag);
}
},
{
immediate: true,
}
);
// slug
const { handleGenerateSlug } = useSlugify(
computed(() => formState.value.spec.displayName),
computed({
get() {
return formState.value.spec.slug;
},
set(value) {
formState.value.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value),
FormType.TAG
);
</script>
<template>
<VModal ref="modal" :title="modalTitle" :width="700" @close="emit('close')">
<template #actions>
<span @click="emit('previous')">
<IconArrowLeft />
</span>
<span @click="emit('next')">
<IconArrowRight />
</span>
</template>
<FormKit
id="tag-form"
type="form"
name="tag-form"
:config="{ validationVisibility: 'submit' }"
@submit="handleSaveTag"
>
<div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post_tag.editing_modal.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
id="displayNameInput"
v-model="formState.spec.displayName"
name="displayName"
:label="
$t('core.post_tag.editing_modal.fields.display_name.label')
"
type="text"
validation="required|length:0,50"
></FormKit>
<FormKit
v-model="formState.spec.slug"
:help="$t('core.post_tag.editing_modal.fields.slug.help')"
:label="$t('core.post_tag.editing_modal.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,50"
>
<template #suffix>
<div
v-tooltip="
$t(
'core.post_tag.editing_modal.fields.slug.refresh_message'
)
"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug(true, FormType.TAG)"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
v-model="formState.spec.color"
name="color"
:help="$t('core.post_tag.editing_modal.fields.color.help')"
:label="$t('core.post_tag.editing_modal.fields.color.label')"
type="color"
validation="length:0,50"
></FormKit>
<FormKit
v-model="formState.spec.cover"
name="cover"
:help="$t('core.post_tag.editing_modal.fields.cover.help')"
:label="$t('core.post_tag.editing_modal.fields.cover.label')"
type="attachment"
:accepts="['image/*']"
validation="length:0,1024"
></FormKit>
</div>
</div>
</div>
</FormKit>
<div class="py-5">
<div class="border-t border-gray-200"></div>
</div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.post_tag.editing_modal.groups.annotations") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<AnnotationsForm
:key="formState.metadata.name"
ref="annotationsFormRef"
:value="formState.metadata.annotations"
kind="Tag"
group="content.halo.run"
/>
</div>
</div>
<template #footer>
<div class="flex justify-between">
<VSpace>
<SubmitButton
:loading="saving && !keepAddingSubmit"
:disabled="saving && keepAddingSubmit"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="handleSubmit"
>
</SubmitButton>
<VButton
v-if="!isUpdateMode"
:loading="saving && keepAddingSubmit"
:disabled="saving && !keepAddingSubmit"
@click="handleSubmit(true)"
>
{{ $t("core.common.buttons.save_and_continue") }}
</VButton>
</VSpace>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</div>
</template>
</VModal>
</template>

View File

@ -0,0 +1,98 @@
<script lang="ts" setup>
import HasPermission from "@/components/permission/HasPermission.vue";
import { formatDatetime } from "@/utils/date";
import type { Tag } from "@halo-dev/api-client";
import {
IconExternalLinkLine,
VDropdownItem,
VEntity,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import PostTag from "./PostTag.vue";
withDefaults(
defineProps<{
tag: Tag;
isSelected?: boolean;
}>(),
{ isSelected: false }
);
const emit = defineEmits<{
(event: "editing", tag: Tag): void;
(event: "delete", tag: Tag): void;
}>();
</script>
<template>
<VEntity :is-selected="isSelected">
<template #checkbox>
<HasPermission :permissions="['system:posts:manage']">
<slot name="checkbox" />
</HasPermission>
</template>
<template #start>
<VEntityField>
<template #title>
<PostTag :tag="tag" />
</template>
<template #description>
<VSpace>
<div
v-if="tag.status?.permalink"
:title="tag.status?.permalink"
target="_blank"
class="truncate text-xs text-gray-500 group-hover:text-gray-900"
>
{{ tag.status.permalink }}
</div>
<a
target="_blank"
:href="tag.status?.permalink"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="tag.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField
:description="
$t('core.common.fields.post_count', {
count: tag.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(tag.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template #dropdownItems>
<HasPermission :permissions="['system:posts:manage']">
<VDropdownItem @click="emit('editing', tag)">
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem type="danger" @click="emit('delete', tag)">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</HasPermission>
</template>
</VEntity>
</template>

View File

@ -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<Tag[] | undefined>;
total: Ref<number>;
hasPrevious: Ref<boolean>;
hasNext: Ref<boolean>;
isLoading: Ref<boolean>;
isFetching: Ref<boolean>;
handleFetchTags: () => Promise<QueryObserverResult<Tag[], unknown>>;
handleDelete: (tag: Tag) => void;
handleDeleteInBatch: (tagNames: string[]) => Promise<void>;
}
export function usePostTag(filterOptions?: {
sort?: Ref<string | undefined>;
page?: Ref<number>;
size?: Ref<number>;
keyword?: Ref<string>;
}): 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<void>((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,
};
}

View File

@ -0,0 +1,28 @@
<script lang="ts" setup>
import { useDashboardStats } from "@console/composables/use-dashboard-stats";
import { IconBookRead, VCard } from "@halo-dev/components";
const { data: stats } = useDashboardStats();
</script>
<template>
<VCard class="h-full" :body-class="['h-full']">
<div class="flex h-full">
<div class="flex items-center gap-4">
<span
class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconBookRead class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">
{{ $t("core.dashboard.widgets.presets.post_stats.title") }}
</span>
<p class="text-2xl font-medium text-gray-900">
{{ stats?.posts || 0 }}
</p>
</div>
</div>
</div>
</VCard>
</template>

View File

@ -0,0 +1,105 @@
<script lang="ts" setup>
import { postLabels } from "@/constants/labels";
import { formatDatetime } from "@/utils/date";
import type { ListedPost } from "@halo-dev/api-client";
import { consoleApiClient } from "@halo-dev/api-client";
import {
IconExternalLinkLine,
VCard,
VEntity,
VEntityField,
VSpace,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
const { data } = useQuery<ListedPost[]>({
queryKey: ["widget-recent-posts"],
queryFn: async () => {
const { data } = await consoleApiClient.content.post.listPosts({
labelSelector: [
`${postLabels.DELETED}=false`,
`${postLabels.PUBLISHED}=true`,
],
sort: ["spec.publishTime,desc"],
page: 1,
size: 10,
});
return data.items;
},
});
</script>
<template>
<VCard
:body-class="['h-full', '!p-0', '!overflow-auto']"
class="h-full"
:title="$t('core.dashboard.widgets.presets.recent_published.title')"
>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="(post, index) in data" :key="index">
<VEntity>
<template #start>
<VEntityField
:title="post.post.spec.title"
:route="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
>
<template #description>
<VSpace>
<span class="text-xs text-gray-500">
{{
$t(
"core.dashboard.widgets.presets.recent_published.visits",
{ visits: post.stats.visit || 0 }
)
}}
</span>
<span class="text-xs text-gray-500">
{{
$t(
"core.dashboard.widgets.presets.recent_published.comments",
{ comments: post.stats.totalComment || 0 }
)
}}
</span>
<span class="truncate text-xs tabular-nums text-gray-500">
{{
$t(
"core.dashboard.widgets.presets.recent_published.publishTime",
{
publishTime: formatDatetime(
post.post.spec.publishTime
),
}
)
}}
</span>
</VSpace>
</template>
<template #extra>
<a
v-if="post.post.status?.permalink"
target="_blank"
:href="post.post.status?.permalink"
:title="post.post.status?.permalink"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</template>
</VEntityField>
</template>
</VEntity>
</li>
</ul>
</OverlayScrollbarsComponent>
</VCard>
</template>

View File

@ -0,0 +1,288 @@
<template>
<VPageHeader :title="$t('core.dashboard.title')">
<template #icon>
<IconDashboard class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton v-if="settings" @click="widgetsModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.dashboard.actions.add_widget") }}
</VButton>
<VButton type="secondary" @click="settings = !settings">
<template #icon>
<IconSettings v-if="!settings" class="h-full w-full" />
<IconSave v-else class="h-full w-full" />
</template>
{{
settings
? $t("core.dashboard.actions.done")
: $t("core.dashboard.actions.setting")
}}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="dashboard m-4">
<grid-layout
v-model:layout="layout"
:col-num="12"
:is-draggable="settings"
:is-resizable="settings"
:margin="[10, 10]"
:responsive="false"
:row-height="30"
:use-css-transforms="true"
:vertical-compact="true"
>
<template v-for="(item, index) in layout" :key="index">
<grid-item
v-if="currentUserHasPermission(item.permissions)"
:h="item.h"
:i="item.i"
:w="item.w"
:x="item.x"
:y="item.y"
>
<component :is="item.widget" />
<div v-if="settings" class="absolute right-2 top-2">
<IconCloseCircle
class="cursor-pointer text-lg text-gray-500 hover:text-gray-900"
@click="handleRemove(item)"
/>
</div>
</grid-item>
</template>
</grid-layout>
</div>
<VModal
v-if="widgetsModal"
height="calc(100vh - 20px)"
:width="1280"
:layer-closable="true"
:title="$t('core.dashboard.widgets.modal_title')"
@close="widgetsModal = false"
>
<VTabbar
v-model:active-id="activeId"
:items="
widgetsGroup.map((group) => {
return { id: group.id, label: group.label };
})
"
type="outline"
></VTabbar>
<div class="mt-4">
<template v-for="(group, groupIndex) in widgetsGroup" :key="groupIndex">
<grid-layout
v-if="activeId === group.id"
:col-num="12"
:is-draggable="false"
:is-resizable="false"
:layout="group.widgets"
:margin="[10, 10]"
:responsive="true"
:row-height="30"
:use-css-transforms="true"
:vertical-compact="true"
>
<template v-for="(item, index) in group.widgets" :key="index">
<grid-item
v-if="currentUserHasPermission(item.permissions)"
:h="item.h"
:i="item.i"
:w="item.w"
:x="item.x"
:y="item.y"
class="cursor-pointer"
@click="handleAddWidget(item)"
>
<component :is="item.widget" />
</grid-item>
</template>
</grid-layout>
</template>
</div>
</VModal>
</template>
<script lang="ts" setup>
import { usePermission } from "@/utils/permission";
import {
IconAddCircle,
IconCloseCircle,
IconDashboard,
IconSave,
IconSettings,
VButton,
VModal,
VPageHeader,
VSpace,
VTabbar,
} from "@halo-dev/components";
import { useStorage } from "@vueuse/core";
import { cloneDeep } from "lodash-es";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const { currentUserHasPermission } = usePermission();
const widgetsGroup = [
{
id: "post",
label: t("core.dashboard.widgets.groups.post"),
widgets: [
{
x: 0,
y: 0,
w: 3,
h: 3,
i: 0,
widget: "PostStatsWidget",
},
{
x: 0,
y: 0,
w: 6,
h: 10,
i: 1,
widget: "RecentPublishedWidget",
permissions: ["system:posts:view"],
},
],
},
{
id: "page",
label: t("core.dashboard.widgets.groups.page"),
widgets: [
{
x: 0,
y: 0,
w: 3,
h: 3,
i: 0,
widget: "SinglePageStatsWidget",
permissions: ["system:singlepages:view"],
},
],
},
{
id: "comment",
label: t("core.dashboard.widgets.groups.comment"),
widgets: [{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "CommentStatsWidget" }],
},
{
id: "user",
label: t("core.dashboard.widgets.groups.user"),
widgets: [{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "UserStatsWidget" }],
},
{
id: "other",
label: t("core.dashboard.widgets.groups.other"),
widgets: [
{ x: 0, y: 0, w: 3, h: 3, i: 0, widget: "ViewsStatsWidget" },
{ x: 0, y: 0, w: 6, h: 10, i: 1, widget: "QuickLinkWidget" },
{ x: 0, y: 0, w: 6, h: 10, i: 2, widget: "NotificationWidget" },
],
},
];
const settings = ref(false);
const widgetsModal = ref(false);
const activeId = ref(widgetsGroup[0].id);
const layout = useStorage("widgets", [
{
x: 0,
y: 0,
w: 3,
h: 3,
i: 0,
widget: "PostStatsWidget",
},
{
x: 3,
y: 0,
w: 3,
h: 3,
i: 1,
widget: "UserStatsWidget",
},
{
x: 6,
y: 0,
w: 3,
h: 3,
i: 2,
widget: "CommentStatsWidget",
},
{
x: 9,
y: 0,
w: 3,
h: 3,
i: 3,
widget: "ViewsStatsWidget",
},
{
x: 0,
y: 3,
w: 6,
h: 12,
i: 4,
widget: "QuickLinkWidget",
},
{
x: 6,
y: 3,
w: 6,
h: 12,
i: 5,
widget: "NotificationWidget",
permissions: [],
},
]);
// eslint-disable-next-line
function handleAddWidget(widget: any) {
layout.value = [
...layout.value,
{
...widget,
i: layout.value.length,
},
];
}
// eslint-disable-next-line
function handleRemove(item: any) {
const cloneWidgets = cloneDeep(layout.value);
cloneWidgets.splice(item.i, 1);
// eslint-disable-next-line
layout.value = cloneWidgets.map((widget: any, index: number) => {
return {
...widget,
i: index,
};
});
}
</script>
<style>
.vue-grid-layout {
@apply -m-[10px];
}
.vue-grid-item {
transition: none !important;
}
.vue-grid-item.vue-grid-placeholder {
@apply bg-gray-200 !important;
@apply opacity-100 !important;
}
</style>

View File

@ -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,
},
},
},
],
},
],
});

View File

@ -0,0 +1,210 @@
<script lang="ts" setup>
import ThemePreviewModal from "@console/modules/interface/themes/components/preview/ThemePreviewModal.vue";
import { consoleApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconAccountCircleLine,
IconArrowRight,
IconBookRead,
IconFolder,
IconPages,
IconPalette,
IconPlug,
IconSearch,
IconUserSettings,
IconWindowLine,
Toast,
VCard,
} from "@halo-dev/components";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { markRaw, ref, type Component } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
interface Action {
icon: Component;
title: string;
action: () => void;
permissions?: string[];
}
const router = useRouter();
const themePreviewVisible = ref(false);
const { t } = useI18n();
const actions: Action[] = [
{
icon: markRaw(IconAccountCircleLine),
title: t(
"core.dashboard.widgets.presets.quicklink.actions.user_center.title"
),
action: () => {
window.location.href = "/uc/profile";
},
},
{
icon: markRaw(IconWindowLine),
title: t(
"core.dashboard.widgets.presets.quicklink.actions.view_site.title"
),
action: () => {
themePreviewVisible.value = true;
},
permissions: ["system:themes:view"],
},
{
icon: markRaw(IconBookRead),
title: t("core.dashboard.widgets.presets.quicklink.actions.new_post.title"),
action: () => {
router.push({
name: "PostEditor",
});
},
permissions: ["system:posts:manage"],
},
{
icon: markRaw(IconPages),
title: t("core.dashboard.widgets.presets.quicklink.actions.new_page.title"),
action: () => {
router.push({
name: "SinglePageEditor",
});
},
permissions: ["system:singlepages:manage"],
},
{
icon: markRaw(IconFolder),
title: t(
"core.dashboard.widgets.presets.quicklink.actions.upload_attachment.title"
),
action: () => {
router.push({
name: "Attachments",
query: {
action: "upload",
},
});
},
permissions: ["system:attachments:manage"],
},
{
icon: markRaw(IconPalette),
title: t(
"core.dashboard.widgets.presets.quicklink.actions.theme_manage.title"
),
action: () => {
router.push({
name: "ThemeDetail",
});
},
permissions: ["system:themes:view"],
},
{
icon: markRaw(IconPlug),
title: t(
"core.dashboard.widgets.presets.quicklink.actions.plugin_manage.title"
),
action: () => {
router.push({
name: "Plugins",
});
},
permissions: ["system:plugins:view"],
},
{
icon: markRaw(IconUserSettings),
title: t("core.dashboard.widgets.presets.quicklink.actions.new_user.title"),
action: () => {
router.push({
name: "Users",
query: {
action: "create",
},
});
},
permissions: ["system:users:manage"],
},
{
icon: markRaw(IconSearch),
title: t(
"core.dashboard.widgets.presets.quicklink.actions.refresh_search_engine.title"
),
action: () => {
Dialog.warning({
title: t(
"core.dashboard.widgets.presets.quicklink.actions.refresh_search_engine.dialog_title"
),
description: t(
"core.dashboard.widgets.presets.quicklink.actions.refresh_search_engine.dialog_content"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await consoleApiClient.content.indices.buildPostIndices();
Toast.success(
t(
"core.dashboard.widgets.presets.quicklink.actions.refresh_search_engine.success_message"
)
);
},
});
},
permissions: ["system:posts:manage"],
},
];
</script>
<template>
<VCard
:body-class="['h-full', '@container', '!p-0', '!overflow-auto']"
class="h-full"
:title="$t('core.dashboard.widgets.presets.quicklink.title')"
>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
style="padding: 12px 16px"
defer
>
<div
class="grid grid-cols-1 gap-2 overflow-hidden @sm:grid-cols-2 @md:grid-cols-3"
>
<div
v-for="(action, index) in actions"
:key="index"
v-permission="action.permissions"
class="group relative cursor-pointer rounded-lg bg-gray-50 p-4 transition-all hover:bg-gray-100"
@click="action.action"
>
<div>
<span
class="inline-flex rounded-lg bg-teal-50 p-3 text-teal-700 ring-4 ring-white"
>
<component :is="action.icon"></component>
</span>
</div>
<div class="mt-8">
<h3 class="text-sm font-semibold">
{{ action.title }}
</h3>
</div>
<span
aria-hidden="true"
class="pointer-events-none absolute right-6 top-6 text-gray-300 transition-all group-hover:translate-x-1 group-hover:text-gray-400"
>
<IconArrowRight />
</span>
</div>
</div>
</OverlayScrollbarsComponent>
</VCard>
<ThemePreviewModal
v-if="themePreviewVisible"
:title="
$t('core.dashboard.widgets.presets.quicklink.actions.view_site.title')
"
@close="themePreviewVisible = false"
/>
</template>

View File

@ -0,0 +1,28 @@
<script lang="ts" setup>
import { useDashboardStats } from "@console/composables/use-dashboard-stats";
import { IconEye, VCard } from "@halo-dev/components";
const { data: stats } = useDashboardStats();
</script>
<template>
<VCard class="h-full" :body-class="['h-full']">
<div class="flex h-full">
<div class="flex items-center gap-4">
<span
class="hidden rounded-full bg-gray-100 p-2.5 text-gray-600 sm:block"
>
<IconEye class="h-5 w-5" />
</span>
<div>
<span class="text-sm text-gray-500">
{{ $t("core.dashboard.widgets.presets.views_stats.title") }}
</span>
<p class="text-2xl font-medium text-gray-900">
{{ stats?.visits || 0 }}
</p>
</div>
</div>
</div>
</VCard>
</template>

Some files were not shown because too many files have changed in this diff Show More