初始化
This commit is contained in:
commit
ca9430f82c
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal 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
11
.changeset/config.json
Normal 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
9
.changeset/pre.json
Normal 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
12
.editorconfig
Normal 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
0
.env.development
Normal file
0
.env.production
Normal file
0
.env.production
Normal file
31
.eslintrc.cjs
Normal file
31
.eslintrc.cjs
Normal 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
32
.gitignore
vendored
Normal 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
15
.gitpod.yml
Normal 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
4
.husky/pre-commit
Normal file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd ui && pnpm exec lint-staged
|
||||
4
.npmignore
Normal file
4
.npmignore
Normal file
@ -0,0 +1,4 @@
|
||||
/node_modules/*
|
||||
/.idea/*
|
||||
/.git/*
|
||||
/.github/*
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@ -0,0 +1,2 @@
|
||||
pnpm-lock.yaml
|
||||
packages/api-client
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"editorconfig.editorconfig",
|
||||
"vue.volar"
|
||||
]
|
||||
}
|
||||
28
Makefile
Normal file
28
Makefile
Normal 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
15
OWNERS
Normal 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
49
README.md
Normal 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
|
||||
```
|
||||
|
||||
## 状态
|
||||
|
||||

|
||||
74
build.gradle
Normal file
74
build.gradle
Normal 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
7
console-src/App.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import BaseApp from "@/components/base-app/BaseApp.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseApp />
|
||||
</template>
|
||||
33
console-src/composables/use-content-snapshot.ts
Normal file
33
console-src/composables/use-content-snapshot.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
console-src/composables/use-dashboard-stats.ts
Normal file
13
console-src/composables/use-dashboard-stats.ts
Normal 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 };
|
||||
}
|
||||
47
console-src/composables/use-entity-extension-points.ts
Normal file
47
console-src/composables/use-entity-extension-points.ts
Normal 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 };
|
||||
}
|
||||
20
console-src/composables/use-global-info.ts
Normal file
20
console-src/composables/use-global-info.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
console-src/composables/use-operation-extension-points.ts
Normal file
36
console-src/composables/use-operation-extension-points.ts
Normal 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 };
|
||||
}
|
||||
21
console-src/composables/use-save-keybinding.ts
Normal file
21
console-src/composables/use-save-keybinding.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
92
console-src/composables/use-setting-form.ts
Normal file
92
console-src/composables/use-setting-form.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
69
console-src/composables/use-slugify.ts
Normal file
69
console-src/composables/use-slugify.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
352
console-src/layouts/BasicLayout.vue
Normal file
352
console-src/layouts/BasicLayout.vue
Normal 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>
|
||||
7
console-src/layouts/BlankLayout.vue
Normal file
7
console-src/layouts/BlankLayout.vue
Normal 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
113
console-src/main.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
627
console-src/modules/contents/attachments/AttachmentList.vue
Normal file
627
console-src/modules/contents/attachments/AttachmentList.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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 ``;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
37
console-src/modules/contents/attachments/module.ts
Normal file
37
console-src/modules/contents/attachments/module.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
413
console-src/modules/contents/comments/CommentList.vue
Normal file
413
console-src/modules/contents/comments/CommentList.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
38
console-src/modules/contents/comments/module.ts
Normal file
38
console-src/modules/contents/comments/module.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -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>
|
||||
402
console-src/modules/contents/pages/DeletedSinglePageList.vue
Normal file
402
console-src/modules/contents/pages/DeletedSinglePageList.vue
Normal 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>
|
||||
546
console-src/modules/contents/pages/SinglePageEditor.vue
Normal file
546
console-src/modules/contents/pages/SinglePageEditor.vue
Normal 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>
|
||||
479
console-src/modules/contents/pages/SinglePageList.vue
Normal file
479
console-src/modules/contents/pages/SinglePageList.vue
Normal 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>
|
||||
182
console-src/modules/contents/pages/SinglePageSnapshots.vue
Normal file
182
console-src/modules/contents/pages/SinglePageSnapshots.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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"));
|
||||
},
|
||||
});
|
||||
}
|
||||
72
console-src/modules/contents/pages/module.ts
Normal file
72
console-src/modules/contents/pages/module.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -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>
|
||||
423
console-src/modules/contents/posts/DeletedPostList.vue
Normal file
423
console-src/modules/contents/posts/DeletedPostList.vue
Normal 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>
|
||||
582
console-src/modules/contents/posts/PostEditor.vue
Normal file
582
console-src/modules/contents/posts/PostEditor.vue
Normal 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>
|
||||
607
console-src/modules/contents/posts/PostList.vue
Normal file
607
console-src/modules/contents/posts/PostList.vue
Normal 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>
|
||||
181
console-src/modules/contents/posts/PostSnapshots.vue
Normal file
181
console-src/modules/contents/posts/PostSnapshots.vue
Normal 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>
|
||||
143
console-src/modules/contents/posts/categories/CategoryList.vue
Normal file
143
console-src/modules/contents/posts/categories/CategoryList.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
146
console-src/modules/contents/posts/categories/utils/index.ts
Normal file
146
console-src/modules/contents/posts/categories/utils/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
242
console-src/modules/contents/posts/components/PostListItem.vue
Normal file
242
console-src/modules/contents/posts/components/PostListItem.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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"));
|
||||
},
|
||||
});
|
||||
}
|
||||
110
console-src/modules/contents/posts/module.ts
Normal file
110
console-src/modules/contents/posts/module.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
319
console-src/modules/contents/posts/tags/TagList.vue
Normal file
319
console-src/modules/contents/posts/tags/TagList.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
288
console-src/modules/dashboard/Dashboard.vue
Normal file
288
console-src/modules/dashboard/Dashboard.vue
Normal 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>
|
||||
41
console-src/modules/dashboard/module.ts
Normal file
41
console-src/modules/dashboard/module.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
210
console-src/modules/dashboard/widgets/QuickLinkWidget.vue
Normal file
210
console-src/modules/dashboard/widgets/QuickLinkWidget.vue
Normal 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>
|
||||
28
console-src/modules/dashboard/widgets/ViewsStatsWidget.vue
Normal file
28
console-src/modules/dashboard/widgets/ViewsStatsWidget.vue
Normal 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
Loading…
x
Reference in New Issue
Block a user