mirror of https://github.com/halo-dev/halo
feat: add secret select input (#6140)
#### What type of PR is this? /area ui /kind feature /milestone 2.17.x #### What this PR does / why we need it: 为 FormKit 添加 Secret 选择组件。 <img width="749" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/5100d7c0-89c0-48d3-9db5-7de67504686e"> #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/5094 #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 为 FormKit 添加 Secret 选择组件。 ```pull/6185/head
parent
0f6722a37e
commit
9d478eecf9
|
@ -28,14 +28,14 @@
|
||||||
- `list`: 动态列表,定义一个数组列表。
|
- `list`: 动态列表,定义一个数组列表。
|
||||||
- 参数
|
- 参数
|
||||||
1. itemType: 列表项的数据类型,用于初始化数据类型,可选参数 `string`, `number`, `boolean`, `object`,默认为 `string`
|
1. itemType: 列表项的数据类型,用于初始化数据类型,可选参数 `string`, `number`, `boolean`, `object`,默认为 `string`
|
||||||
1. min: 最小数量,默认为 `0`
|
2. min: 最小数量,默认为 `0`
|
||||||
2. max: 最大数量,默认为 `Infinity`,即无限制。
|
3. max: 最大数量,默认为 `Infinity`,即无限制。
|
||||||
3. addLabel: 添加按钮的文本,默认为 `添加`
|
4. addLabel: 添加按钮的文本,默认为 `添加`
|
||||||
4. addButton: 是否显示添加按钮,默认为 `true`
|
5. addButton: 是否显示添加按钮,默认为 `true`
|
||||||
5. upControl: 是否显示上移按钮,默认为 `true`
|
6. upControl: 是否显示上移按钮,默认为 `true`
|
||||||
6. downControl: 是否显示下移按钮,默认为 `true`
|
7. downControl: 是否显示下移按钮,默认为 `true`
|
||||||
7. insertControl: 是否显示插入按钮,默认为 `true`
|
8. insertControl: 是否显示插入按钮,默认为 `true`
|
||||||
8. removeControl: 是否显示删除按钮,默认为 `true`
|
9. removeControl: 是否显示删除按钮,默认为 `true`
|
||||||
- `menuCheckbox`:选择一组菜单
|
- `menuCheckbox`:选择一组菜单
|
||||||
- `menuRadio`:选择一个菜单
|
- `menuRadio`:选择一个菜单
|
||||||
- `menuItemSelect`:选择菜单项
|
- `menuItemSelect`:选择菜单项
|
||||||
|
@ -54,6 +54,9 @@
|
||||||
1. action: 对目标数据进行验证的接口地址
|
1. action: 对目标数据进行验证的接口地址
|
||||||
2. label: 验证按钮文本
|
2. label: 验证按钮文本
|
||||||
3. buttonAttrs: 验证按钮的额外属性
|
3. buttonAttrs: 验证按钮的额外属性
|
||||||
|
- `secret`: 用于选择或者管理密钥(Secret)
|
||||||
|
- 参数
|
||||||
|
1. requiredKey:用于确认所需密钥的字段名称
|
||||||
|
|
||||||
在 Vue 单组件中使用:
|
在 Vue 单组件中使用:
|
||||||
|
|
||||||
|
@ -131,7 +134,6 @@ const users = ref([]);
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> `list` 组件有且只有一个子节点,并且必须为子节点传递 `index` 属性。若想提供多个字段,则建议使用 `group` 组件包裹。
|
> `list` 组件有且只有一个子节点,并且必须为子节点传递 `index` 属性。若想提供多个字段,则建议使用 `group` 组件包裹。
|
||||||
|
|
||||||
|
|
||||||
最终得到的数据类似于:
|
最终得到的数据类似于:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
@ -143,7 +145,6 @@ const users = ref([]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Repeater
|
### Repeater
|
||||||
|
|
||||||
Repeater 是一个集合类型的输入组件,可以让使用者可视化的操作集合。
|
Repeater 是一个集合类型的输入组件,可以让使用者可视化的操作集合。
|
||||||
|
|
|
@ -29,3 +29,8 @@ export enum contentAnnotations {
|
||||||
export enum patAnnotations {
|
export enum patAnnotations {
|
||||||
ACCESS_TOKEN = "security.halo.run/access-token",
|
ACCESS_TOKEN = "security.halo.run/access-token",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secret
|
||||||
|
export enum secretAnnotations {
|
||||||
|
DESCRIPTION = "secret.halo.run/description",
|
||||||
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { password } from "./inputs/password";
|
||||||
import { postSelect } from "./inputs/post-select";
|
import { postSelect } from "./inputs/post-select";
|
||||||
import { repeater } from "./inputs/repeater";
|
import { repeater } from "./inputs/repeater";
|
||||||
import { roleSelect } from "./inputs/role-select";
|
import { roleSelect } from "./inputs/role-select";
|
||||||
|
import { secret } from "./inputs/secret";
|
||||||
import { singlePageSelect } from "./inputs/singlePage-select";
|
import { singlePageSelect } from "./inputs/singlePage-select";
|
||||||
import { tagCheckbox } from "./inputs/tag-checkbox";
|
import { tagCheckbox } from "./inputs/tag-checkbox";
|
||||||
import { tagSelect } from "./inputs/tag-select";
|
import { tagSelect } from "./inputs/tag-select";
|
||||||
|
@ -42,26 +43,27 @@ const config: DefaultConfigOptions = {
|
||||||
autoScrollToErrors,
|
autoScrollToErrors,
|
||||||
],
|
],
|
||||||
inputs: {
|
inputs: {
|
||||||
list,
|
|
||||||
form,
|
|
||||||
password,
|
|
||||||
group,
|
|
||||||
nativeGroup,
|
|
||||||
attachment,
|
attachment,
|
||||||
code,
|
|
||||||
repeater,
|
|
||||||
menuCheckbox,
|
|
||||||
menuRadio,
|
|
||||||
menuItemSelect,
|
|
||||||
postSelect,
|
|
||||||
categorySelect,
|
|
||||||
tagSelect,
|
|
||||||
singlePageSelect,
|
|
||||||
categoryCheckbox,
|
|
||||||
tagCheckbox,
|
|
||||||
roleSelect,
|
|
||||||
attachmentPolicySelect,
|
|
||||||
attachmentGroupSelect,
|
attachmentGroupSelect,
|
||||||
|
attachmentPolicySelect,
|
||||||
|
categoryCheckbox,
|
||||||
|
categorySelect,
|
||||||
|
code,
|
||||||
|
form,
|
||||||
|
group,
|
||||||
|
list,
|
||||||
|
menuCheckbox,
|
||||||
|
menuItemSelect,
|
||||||
|
menuRadio,
|
||||||
|
nativeGroup,
|
||||||
|
password,
|
||||||
|
postSelect,
|
||||||
|
repeater,
|
||||||
|
roleSelect,
|
||||||
|
secret,
|
||||||
|
singlePageSelect,
|
||||||
|
tagCheckbox,
|
||||||
|
tagSelect,
|
||||||
verificationForm,
|
verificationForm,
|
||||||
},
|
},
|
||||||
locales: { zh, en },
|
locales: { zh, en },
|
||||||
|
|
|
@ -0,0 +1,316 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { secretAnnotations } from "@/constants/annotations";
|
||||||
|
import type { FormKitFrameworkContext } from "@formkit/core";
|
||||||
|
import { coreApiClient, type Secret } from "@halo-dev/api-client";
|
||||||
|
import {
|
||||||
|
IconArrowRight,
|
||||||
|
IconCheckboxCircle,
|
||||||
|
IconClose,
|
||||||
|
IconSettings,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import { onClickOutside } from "@vueuse/core";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { computed, ref, watch, type PropType } from "vue";
|
||||||
|
import SecretEditModal from "./components/SecretEditModal.vue";
|
||||||
|
import SecretListModal from "./components/SecretListModal.vue";
|
||||||
|
import { Q_KEY, useSecretsFetch } from "./composables/use-secrets-fetch";
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
context: {
|
||||||
|
type: Object as PropType<FormKitFrameworkContext>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSecret = ref<Secret>();
|
||||||
|
const dropdownVisible = ref(false);
|
||||||
|
const text = ref("");
|
||||||
|
const wrapperRef = ref<HTMLElement>();
|
||||||
|
|
||||||
|
const { data } = useSecretsFetch();
|
||||||
|
|
||||||
|
onClickOutside(wrapperRef, () => {
|
||||||
|
dropdownVisible.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// search
|
||||||
|
let fuse: Fuse<Secret> | undefined = undefined;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => data.value,
|
||||||
|
() => {
|
||||||
|
fuse = new Fuse(data.value?.items || [], {
|
||||||
|
keys: ["metadata.name", "metadata.stringData"],
|
||||||
|
useExtendedSearch: true,
|
||||||
|
threshold: 0.2,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchResults = computed(() => {
|
||||||
|
if (!text.value) {
|
||||||
|
return data.value?.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fuse?.search(text.value).map((item) => item.item) || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => searchResults.value,
|
||||||
|
(value) => {
|
||||||
|
if (value?.length && text.value) {
|
||||||
|
selectedSecret.value = value[0];
|
||||||
|
scrollToSelected();
|
||||||
|
} else {
|
||||||
|
selectedSecret.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (!searchResults.value) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const index = searchResults.value.findIndex(
|
||||||
|
(secret) => secret.metadata.name === selectedSecret.value?.metadata.name
|
||||||
|
);
|
||||||
|
if (index < searchResults.value.length - 1) {
|
||||||
|
selectedSecret.value = searchResults.value[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToSelected();
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const index = searchResults.value.findIndex(
|
||||||
|
(secret) => secret.metadata.name === selectedSecret.value?.metadata.name
|
||||||
|
);
|
||||||
|
if (index > 0) {
|
||||||
|
selectedSecret.value = searchResults.value[index - 1];
|
||||||
|
} else {
|
||||||
|
selectedSecret.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (!selectedSecret.value && text.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateSecret();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSecret.value) {
|
||||||
|
handleSelect(selectedSecret.value);
|
||||||
|
text.value = "";
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToSelected = () => {
|
||||||
|
const selectedNodeName = selectedSecret.value
|
||||||
|
? selectedSecret.value?.metadata.name
|
||||||
|
: "secret-create";
|
||||||
|
|
||||||
|
const selectedNode = document.getElementById(selectedNodeName);
|
||||||
|
|
||||||
|
if (selectedNode) {
|
||||||
|
selectedNode.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
inline: "start",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check required key and edit secret
|
||||||
|
function hasRequiredKey(secret: Secret) {
|
||||||
|
return !!secret.stringData?.[props.context.requiredKey as string];
|
||||||
|
}
|
||||||
|
const secretToUpdate = ref<Secret>();
|
||||||
|
const secretEditModalVisible = ref(false);
|
||||||
|
|
||||||
|
const handleSelect = (secret?: Secret) => {
|
||||||
|
if (!secret || secret.metadata.name === props.context._value) {
|
||||||
|
props.context.node.input("");
|
||||||
|
dropdownVisible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.context.node.input(secret.metadata.name);
|
||||||
|
|
||||||
|
text.value = "";
|
||||||
|
|
||||||
|
dropdownVisible.value = false;
|
||||||
|
|
||||||
|
// Check required key and open edit modal
|
||||||
|
if (!hasRequiredKey(secret)) {
|
||||||
|
const stringDataToUpdate = {
|
||||||
|
...secret.stringData,
|
||||||
|
[props.context.requiredKey as string]: "",
|
||||||
|
};
|
||||||
|
secretToUpdate.value = {
|
||||||
|
...secret,
|
||||||
|
stringData: stringDataToUpdate,
|
||||||
|
};
|
||||||
|
secretEditModalVisible.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTextInput = (e: Event) => {
|
||||||
|
text.value = (e.target as HTMLInputElement).value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const secretListModalVisible = ref(false);
|
||||||
|
|
||||||
|
// Create new secret
|
||||||
|
async function handleCreateSecret() {
|
||||||
|
const { data: newSecret } = await coreApiClient.secret.createSecret({
|
||||||
|
secret: {
|
||||||
|
metadata: {
|
||||||
|
generateName: "secret-",
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
kind: "Secret",
|
||||||
|
apiVersion: "v1alpha1",
|
||||||
|
type: "Opaque",
|
||||||
|
stringData: {
|
||||||
|
[props.context.requiredKey as string]: text.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: Q_KEY() });
|
||||||
|
|
||||||
|
handleSelect(newSecret);
|
||||||
|
|
||||||
|
text.value = "";
|
||||||
|
|
||||||
|
dropdownVisible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SecretListModal
|
||||||
|
v-if="secretListModalVisible"
|
||||||
|
@close="secretListModalVisible = false"
|
||||||
|
/>
|
||||||
|
<SecretEditModal
|
||||||
|
v-if="secretEditModalVisible && secretToUpdate"
|
||||||
|
:secret="secretToUpdate"
|
||||||
|
@close="secretEditModalVisible = false"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref="wrapperRef"
|
||||||
|
class="flex h-full w-full items-center border border-gray-300 transition-all"
|
||||||
|
:class="[
|
||||||
|
{ 'pointer-events-none': context.disabled },
|
||||||
|
{ 'border-primary shadow-sm': dropdownVisible },
|
||||||
|
]"
|
||||||
|
style="border-radius: inherit"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-full min-w-0 flex-1 shrink flex-wrap items-center"
|
||||||
|
@click="dropdownVisible = true"
|
||||||
|
>
|
||||||
|
<div v-if="context._value" class="flex px-3 text-sm text-black">
|
||||||
|
{{ context._value }}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
:value="text"
|
||||||
|
:class="context.classes.input"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('core.formkit.secret.placeholder')"
|
||||||
|
@input="onTextInput"
|
||||||
|
@focus="dropdownVisible = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline-flex h-full flex-none items-center gap-2">
|
||||||
|
<div v-if="context._value" class="cursor-pointer" @click="handleSelect()">
|
||||||
|
<IconClose
|
||||||
|
class="rotate-90 text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex h-full items-center">
|
||||||
|
<div
|
||||||
|
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
|
||||||
|
@click="dropdownVisible = !dropdownVisible"
|
||||||
|
>
|
||||||
|
<IconArrowRight class="rotate-90 text-gray-500 hover:text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="group flex h-full cursor-pointer items-center rounded-r-base border-l px-3 transition-all hover:bg-gray-100"
|
||||||
|
@click="secretListModalVisible = true"
|
||||||
|
>
|
||||||
|
<IconSettings class="h-4 w-4 text-gray-500 hover:text-gray-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="dropdownVisible"
|
||||||
|
class="absolute bottom-auto right-0 top-full z-10 mt-1 max-h-96 w-full overflow-auto rounded bg-white shadow-lg ring-1 ring-gray-100"
|
||||||
|
>
|
||||||
|
<ul class="p-1">
|
||||||
|
<li
|
||||||
|
v-if="text.trim()"
|
||||||
|
id="secret-create"
|
||||||
|
class="group flex cursor-pointer items-center justify-between rounded p-2"
|
||||||
|
:class="{
|
||||||
|
'bg-gray-100': selectedSecret === undefined,
|
||||||
|
}"
|
||||||
|
@click="handleCreateSecret"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-700 group-hover:text-gray-900">
|
||||||
|
{{ $t("core.formkit.secret.creation_label") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="secret in searchResults"
|
||||||
|
:id="secret.metadata.name"
|
||||||
|
:key="secret.metadata.name"
|
||||||
|
class="group flex cursor-pointer items-center rounded p-2 hover:bg-gray-100"
|
||||||
|
:class="{
|
||||||
|
'bg-gray-100':
|
||||||
|
selectedSecret?.metadata.name === secret.metadata.name,
|
||||||
|
}"
|
||||||
|
@click="handleSelect(secret)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="inline-flex min-w-0 flex-1 shrink items-center space-x-2 overflow-hidden text-sm"
|
||||||
|
>
|
||||||
|
<span class="flex-none"> {{ secret.metadata.name }}</span>
|
||||||
|
<span
|
||||||
|
class="line-clamp-1 min-w-0 flex-1 shrink break-words text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
hasRequiredKey(secret)
|
||||||
|
? secret.metadata.annotations?.[secretAnnotations.DESCRIPTION]
|
||||||
|
: $t("core.formkit.secret.required_key_missing_label")
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IconCheckboxCircle
|
||||||
|
v-if="context._value === secret.metadata.name"
|
||||||
|
class="flex-none text-primary"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { secretAnnotations } from "@/constants/annotations";
|
||||||
|
import { coreApiClient } 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";
|
||||||
|
import { Q_KEY } from "../composables/use-secrets-fetch";
|
||||||
|
import type { SecretFormState } from "../types";
|
||||||
|
import SecretForm from "./SecretForm.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof VModal> | null>(null);
|
||||||
|
|
||||||
|
const { mutate, isLoading } = useMutation({
|
||||||
|
mutationKey: ["create-secret"],
|
||||||
|
mutationFn: async ({ data }: { data: SecretFormState }) => {
|
||||||
|
const stringData = data.stringDataArray.reduce((acc, { key, value }) => {
|
||||||
|
acc[key] = value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
return await coreApiClient.secret.createSecret({
|
||||||
|
secret: {
|
||||||
|
metadata: {
|
||||||
|
generateName: "secret-",
|
||||||
|
name: "",
|
||||||
|
annotations: {
|
||||||
|
[secretAnnotations.DESCRIPTION]: data.description + "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kind: "Secret",
|
||||||
|
apiVersion: "v1alpha1",
|
||||||
|
type: "Opaque",
|
||||||
|
stringData: stringData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: Q_KEY() });
|
||||||
|
Toast.success(t("core.common.toast.save_success"));
|
||||||
|
modal.value?.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: SecretFormState) {
|
||||||
|
mutate({ data });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VModal
|
||||||
|
ref="modal"
|
||||||
|
:title="$t('core.formkit.secret.creation_modal.title')"
|
||||||
|
:width="600"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<SecretForm @submit="onSubmit" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<VSpace>
|
||||||
|
<VButton
|
||||||
|
:loading="isLoading"
|
||||||
|
type="secondary"
|
||||||
|
@click="$formkit.submit('secret-form')"
|
||||||
|
>
|
||||||
|
{{ $t("core.common.buttons.save") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton @click="modal?.close()">
|
||||||
|
{{ $t("core.common.buttons.close") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
</template>
|
|
@ -0,0 +1,102 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { secretAnnotations } from "@/constants/annotations";
|
||||||
|
import { coreApiClient, type Secret } 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";
|
||||||
|
import { Q_KEY } from "../composables/use-secrets-fetch";
|
||||||
|
import type { SecretFormState } from "../types";
|
||||||
|
import SecretForm from "./SecretForm.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
secret: Secret;
|
||||||
|
}>(),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof VModal> | null>(null);
|
||||||
|
|
||||||
|
const { mutate, isLoading } = useMutation({
|
||||||
|
mutationKey: ["create-secret"],
|
||||||
|
mutationFn: async ({ data }: { data: SecretFormState }) => {
|
||||||
|
const stringData = data.stringDataArray.reduce((acc, { key, value }) => {
|
||||||
|
acc[key] = value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
return await coreApiClient.secret.patchSecret({
|
||||||
|
name: props.secret.metadata.name,
|
||||||
|
jsonPatchInner: [
|
||||||
|
{
|
||||||
|
op: "add",
|
||||||
|
path: "/stringData",
|
||||||
|
value: stringData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: "add",
|
||||||
|
path: `/metadata/annotations`,
|
||||||
|
value: {
|
||||||
|
...props.secret.metadata.annotations,
|
||||||
|
[secretAnnotations.DESCRIPTION]: data.description,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: Q_KEY() });
|
||||||
|
Toast.success(t("core.common.toast.save_success"));
|
||||||
|
modal.value?.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: SecretFormState) {
|
||||||
|
mutate({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formState: SecretFormState = {
|
||||||
|
description:
|
||||||
|
props.secret.metadata.annotations?.[secretAnnotations.DESCRIPTION],
|
||||||
|
stringDataArray: Object.entries(props.secret.stringData || {}).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VModal
|
||||||
|
ref="modal"
|
||||||
|
:title="$t('core.formkit.secret.edit_modal.title')"
|
||||||
|
:width="600"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<SecretForm :form-state="formState" @submit="onSubmit" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<VSpace>
|
||||||
|
<VButton
|
||||||
|
:loading="isLoading"
|
||||||
|
type="secondary"
|
||||||
|
@click="$formkit.submit('secret-form')"
|
||||||
|
>
|
||||||
|
{{ $t("core.common.buttons.save") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton @click="modal?.close()">
|
||||||
|
{{ $t("core.common.buttons.close") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
</template>
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref, toRaw } from "vue";
|
||||||
|
import type { SecretFormState } from "../types";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
formState?: SecretFormState;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
formState: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultValue = ref({});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.formState) {
|
||||||
|
defaultValue.value = toRaw(props.formState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "submit", data: SecretFormState): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function onSubmit(data: SecretFormState) {
|
||||||
|
emit("submit", data);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormKit
|
||||||
|
id="secret-form"
|
||||||
|
type="form"
|
||||||
|
:model-value="defaultValue"
|
||||||
|
name="secret-form"
|
||||||
|
:config="{ validationVisibility: 'submit' }"
|
||||||
|
@submit="onSubmit"
|
||||||
|
>
|
||||||
|
<FormKit
|
||||||
|
:label="$t('core.formkit.secret.form.fields.description')"
|
||||||
|
name="description"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
type="repeater"
|
||||||
|
name="stringDataArray"
|
||||||
|
:label="$t('core.formkit.secret.form.fields.string_data')"
|
||||||
|
>
|
||||||
|
<FormKit validation="required" name="key" label="Key"></FormKit>
|
||||||
|
<FormKit
|
||||||
|
type="code"
|
||||||
|
validation="required"
|
||||||
|
name="value"
|
||||||
|
label="Value"
|
||||||
|
></FormKit>
|
||||||
|
</FormKit>
|
||||||
|
</FormKit>
|
||||||
|
</template>
|
|
@ -0,0 +1,84 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { secretAnnotations } from "@/constants/annotations";
|
||||||
|
import { coreApiClient, type Secret } from "@halo-dev/api-client";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Toast,
|
||||||
|
VDropdownDivider,
|
||||||
|
VDropdownItem,
|
||||||
|
VEntity,
|
||||||
|
VEntityField,
|
||||||
|
VStatusDot,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { Q_KEY } from "../composables/use-secrets-fetch";
|
||||||
|
import SecretEditModal from "./SecretEditModal.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
secret: Secret;
|
||||||
|
}>(),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
Dialog.warning({
|
||||||
|
title: t("core.formkit.secret.operations.delete.title"),
|
||||||
|
description: t("core.formkit.secret.operations.delete.description"),
|
||||||
|
confirmType: "danger",
|
||||||
|
async onConfirm() {
|
||||||
|
await coreApiClient.secret.deleteSecret({
|
||||||
|
name: props.secret.metadata.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(t("core.common.toast.delete_success"));
|
||||||
|
queryClient.invalidateQueries({ queryKey: Q_KEY() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const editModalVisible = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SecretEditModal
|
||||||
|
v-if="editModalVisible"
|
||||||
|
:secret="secret"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
/>
|
||||||
|
<VEntity>
|
||||||
|
<template #start>
|
||||||
|
<VEntityField
|
||||||
|
:title="secret.metadata.name"
|
||||||
|
:description="
|
||||||
|
secret.metadata.annotations?.[secretAnnotations.DESCRIPTION]
|
||||||
|
"
|
||||||
|
></VEntityField>
|
||||||
|
</template>
|
||||||
|
<template #end>
|
||||||
|
<VEntityField v-if="secret.metadata.deletionTimestamp">
|
||||||
|
<template #description>
|
||||||
|
<VStatusDot
|
||||||
|
v-tooltip="$t('core.common.status.deleting')"
|
||||||
|
state="warning"
|
||||||
|
animate
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VEntityField>
|
||||||
|
</template>
|
||||||
|
<template #dropdownItems>
|
||||||
|
<VDropdownItem @click="editModalVisible = true">
|
||||||
|
{{ $t("core.common.buttons.edit") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
<VDropdownDivider />
|
||||||
|
<VDropdownItem type="danger" @click="handleDelete">
|
||||||
|
{{ $t("core.common.buttons.delete") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
</template>
|
||||||
|
</VEntity>
|
||||||
|
</template>
|
|
@ -0,0 +1,51 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { IconAddCircle, VButton, VModal } from "@halo-dev/components";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useSecretsFetch } from "../composables/use-secrets-fetch";
|
||||||
|
import SecretCreationModal from "./SecretCreationModal.vue";
|
||||||
|
import SecretListItem from "./SecretListItem.vue";
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof VModal> | null>(null);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { data } = useSecretsFetch();
|
||||||
|
|
||||||
|
const creationModalVisible = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VModal
|
||||||
|
ref="modal"
|
||||||
|
:body-class="['!p-0']"
|
||||||
|
:title="$t('core.formkit.secret.list_modal.title')"
|
||||||
|
:width="650"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<span
|
||||||
|
v-tooltip="$t('core.common.buttons.new')"
|
||||||
|
@click="creationModalVisible = true"
|
||||||
|
>
|
||||||
|
<IconAddCircle />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
|
||||||
|
<li v-for="secret in data?.items" :key="secret.metadata.name">
|
||||||
|
<SecretListItem :secret="secret" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<template #footer>
|
||||||
|
<VButton @click="modal?.close()">
|
||||||
|
{{ $t("core.common.buttons.close") }}
|
||||||
|
</VButton>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
|
||||||
|
<SecretCreationModal
|
||||||
|
v-if="creationModalVisible"
|
||||||
|
@close="creationModalVisible = false"
|
||||||
|
/>
|
||||||
|
</template>
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { coreApiClient } from "@halo-dev/api-client";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
|
||||||
|
export const Q_KEY = () => ["secrets"];
|
||||||
|
|
||||||
|
export function useSecretsFetch() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: Q_KEY(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await coreApiClient.secret.listSecret();
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
refetchInterval(data) {
|
||||||
|
const hasDeletingData = data?.items.some(
|
||||||
|
(item) => !!item.metadata.deletionTimestamp
|
||||||
|
);
|
||||||
|
return hasDeletingData ? 1000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { FormKitTypeDefinition } from "@formkit/core";
|
||||||
|
import {
|
||||||
|
help,
|
||||||
|
icon,
|
||||||
|
inner,
|
||||||
|
label,
|
||||||
|
message,
|
||||||
|
messages,
|
||||||
|
outer,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
wrapper,
|
||||||
|
} from "@formkit/inputs";
|
||||||
|
import SecretSelect from "./SecretSelect.vue";
|
||||||
|
import { SecretSection } from "./sections";
|
||||||
|
|
||||||
|
export const secret: FormKitTypeDefinition = {
|
||||||
|
schema: outer(
|
||||||
|
wrapper(
|
||||||
|
label("$label"),
|
||||||
|
inner(icon("prefix"), prefix(), SecretSection(), suffix(), icon("suffix"))
|
||||||
|
),
|
||||||
|
help("$help"),
|
||||||
|
messages(message("$message.value"))
|
||||||
|
),
|
||||||
|
type: "input",
|
||||||
|
props: ["requiredKey"],
|
||||||
|
library: {
|
||||||
|
SecretSelect: SecretSelect,
|
||||||
|
},
|
||||||
|
schemaMemoKey: "custom-secret-select",
|
||||||
|
};
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { createSection } from "@formkit/inputs";
|
||||||
|
|
||||||
|
export const SecretSection = createSection("SecretSection", () => ({
|
||||||
|
$cmp: "SecretSelect",
|
||||||
|
props: {
|
||||||
|
context: "$node.context",
|
||||||
|
},
|
||||||
|
}));
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface SecretFormState {
|
||||||
|
description?: string;
|
||||||
|
stringDataArray: { key: string; value: string }[];
|
||||||
|
}
|
|
@ -148,6 +148,11 @@ const theme: Record<string, Record<string, string>> = {
|
||||||
"dropdown-wrapper":
|
"dropdown-wrapper":
|
||||||
"absolute ring-1 ring-gray-100 top-full bottom-auto right-0 z-10 mt-1 max-h-96 w-full overflow-auto rounded bg-white shadow-lg",
|
"absolute ring-1 ring-gray-100 top-full bottom-auto right-0 z-10 mt-1 max-h-96 w-full overflow-auto rounded bg-white shadow-lg",
|
||||||
},
|
},
|
||||||
|
secret: {
|
||||||
|
...textClassification,
|
||||||
|
inner: `${textClassification.inner} !overflow-visible min-h-[2.25rem] !border-none`,
|
||||||
|
input: `w-0 flex-grow bg-transparent py-1 px-3 block transition-all text-sm`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default theme;
|
export default theme;
|
||||||
|
|
|
@ -428,7 +428,10 @@ core:
|
||||||
prevent_parent_post_cascade_query: >-
|
prevent_parent_post_cascade_query: >-
|
||||||
Prevent parent category from including this category and its
|
Prevent parent category from including this category and its
|
||||||
subcategories in cascade post queries
|
subcategories in cascade post queries
|
||||||
hide_from_list: This category is hidden, This category and its subcategories, as well as its posts, will not be displayed in the front-end list. You need to actively visit the category archive page
|
hide_from_list: >-
|
||||||
|
This category is hidden, This category and its subcategories, as well
|
||||||
|
as its posts, will not be displayed in the front-end list. You need to
|
||||||
|
actively visit the category archive page
|
||||||
page:
|
page:
|
||||||
title: Pages
|
title: Pages
|
||||||
actions:
|
actions:
|
||||||
|
@ -1644,6 +1647,27 @@ core:
|
||||||
no_action_defined: "{label} interface not defined"
|
no_action_defined: "{label} interface not defined"
|
||||||
verify_success: "{label} successful"
|
verify_success: "{label} successful"
|
||||||
verify_failed: "{label} failed"
|
verify_failed: "{label} failed"
|
||||||
|
secret:
|
||||||
|
creation_label: Create a new secret based on the text entered
|
||||||
|
placeholder: Search for an existing secret or enter new content to create one
|
||||||
|
required_key_missing_label: The needed fields are missing, Please select and complete them
|
||||||
|
creation_modal:
|
||||||
|
title: Create secret
|
||||||
|
edit_modal:
|
||||||
|
title: Edit secret
|
||||||
|
list_modal:
|
||||||
|
title: Secrets
|
||||||
|
operations:
|
||||||
|
delete:
|
||||||
|
title: Delete secret
|
||||||
|
description: >-
|
||||||
|
Are you sure you want to delete this secret? Please make sure that
|
||||||
|
this secret is not being used anywhere, otherwise you need to reset
|
||||||
|
it in a specific place
|
||||||
|
form:
|
||||||
|
fields:
|
||||||
|
description: Description
|
||||||
|
string_data: String Data
|
||||||
common:
|
common:
|
||||||
buttons:
|
buttons:
|
||||||
save: Save
|
save: Save
|
||||||
|
|
|
@ -1567,6 +1567,24 @@ core:
|
||||||
no_action_defined: 未定义{label}接口
|
no_action_defined: 未定义{label}接口
|
||||||
verify_success: "{label}成功"
|
verify_success: "{label}成功"
|
||||||
verify_failed: "{label}失败"
|
verify_failed: "{label}失败"
|
||||||
|
secret:
|
||||||
|
creation_label: 根据输入的文本创建新密钥
|
||||||
|
placeholder: 搜索已存在的密钥或者输入内容以创建新的密钥
|
||||||
|
required_key_missing_label: 缺少当前选项所需的字段,请选择之后补充完整
|
||||||
|
creation_modal:
|
||||||
|
title: 创建密钥
|
||||||
|
edit_modal:
|
||||||
|
title: 编辑密钥
|
||||||
|
list_modal:
|
||||||
|
title: 管理密钥
|
||||||
|
operations:
|
||||||
|
delete:
|
||||||
|
title: 删除密钥
|
||||||
|
description: 确定删除此密钥吗?请确保没有地方正在使用此密钥,否则需要在具体的地方重新设置
|
||||||
|
form:
|
||||||
|
fields:
|
||||||
|
description: 备注
|
||||||
|
string_data: 字符串数据
|
||||||
common:
|
common:
|
||||||
buttons:
|
buttons:
|
||||||
save: 保存
|
save: 保存
|
||||||
|
|
|
@ -1524,6 +1524,24 @@ core:
|
||||||
no_action_defined: 未定義{label}介面
|
no_action_defined: 未定義{label}介面
|
||||||
verify_success: "{label}成功"
|
verify_success: "{label}成功"
|
||||||
verify_failed: "{label}失敗"
|
verify_failed: "{label}失敗"
|
||||||
|
secret:
|
||||||
|
creation_label: 根據輸入的文本創建新密鈅
|
||||||
|
placeholder: 搜索已存在的密鈅或者輸入內容以創建新的密鈅
|
||||||
|
required_key_missing_label: 缺少當前選項所需的字段,請選擇之後補充完整
|
||||||
|
creation_modal:
|
||||||
|
title: 創建密鈅
|
||||||
|
edit_modal:
|
||||||
|
title: 編輯密鈅
|
||||||
|
list_modal:
|
||||||
|
title: 管理密鈅
|
||||||
|
operations:
|
||||||
|
delete:
|
||||||
|
title: 刪除密鈅
|
||||||
|
description: 確定刪除此密鈅嗎?請確保沒有地方正在使用此密鈅,否則需要在具體的地方重新設置
|
||||||
|
form:
|
||||||
|
fields:
|
||||||
|
description: 備註
|
||||||
|
string_data: 字符串數據
|
||||||
common:
|
common:
|
||||||
buttons:
|
buttons:
|
||||||
save: 保存
|
save: 保存
|
||||||
|
|
Loading…
Reference in New Issue