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`: 动态列表,定义一个数组列表。
|
||||
- 参数
|
||||
1. itemType: 列表项的数据类型,用于初始化数据类型,可选参数 `string`, `number`, `boolean`, `object`,默认为 `string`
|
||||
1. min: 最小数量,默认为 `0`
|
||||
2. max: 最大数量,默认为 `Infinity`,即无限制。
|
||||
3. addLabel: 添加按钮的文本,默认为 `添加`
|
||||
4. addButton: 是否显示添加按钮,默认为 `true`
|
||||
5. upControl: 是否显示上移按钮,默认为 `true`
|
||||
6. downControl: 是否显示下移按钮,默认为 `true`
|
||||
7. insertControl: 是否显示插入按钮,默认为 `true`
|
||||
8. removeControl: 是否显示删除按钮,默认为 `true`
|
||||
2. min: 最小数量,默认为 `0`
|
||||
3. max: 最大数量,默认为 `Infinity`,即无限制。
|
||||
4. addLabel: 添加按钮的文本,默认为 `添加`
|
||||
5. addButton: 是否显示添加按钮,默认为 `true`
|
||||
6. upControl: 是否显示上移按钮,默认为 `true`
|
||||
7. downControl: 是否显示下移按钮,默认为 `true`
|
||||
8. insertControl: 是否显示插入按钮,默认为 `true`
|
||||
9. removeControl: 是否显示删除按钮,默认为 `true`
|
||||
- `menuCheckbox`:选择一组菜单
|
||||
- `menuRadio`:选择一个菜单
|
||||
- `menuItemSelect`:选择菜单项
|
||||
|
@ -54,6 +54,9 @@
|
|||
1. action: 对目标数据进行验证的接口地址
|
||||
2. label: 验证按钮文本
|
||||
3. buttonAttrs: 验证按钮的额外属性
|
||||
- `secret`: 用于选择或者管理密钥(Secret)
|
||||
- 参数
|
||||
1. requiredKey:用于确认所需密钥的字段名称
|
||||
|
||||
在 Vue 单组件中使用:
|
||||
|
||||
|
@ -131,7 +134,6 @@ const users = ref([]);
|
|||
> [!NOTE]
|
||||
> `list` 组件有且只有一个子节点,并且必须为子节点传递 `index` 属性。若想提供多个字段,则建议使用 `group` 组件包裹。
|
||||
|
||||
|
||||
最终得到的数据类似于:
|
||||
|
||||
```json
|
||||
|
@ -143,7 +145,6 @@ const users = ref([]);
|
|||
}
|
||||
```
|
||||
|
||||
|
||||
### Repeater
|
||||
|
||||
Repeater 是一个集合类型的输入组件,可以让使用者可视化的操作集合。
|
||||
|
|
|
@ -29,3 +29,8 @@ export enum contentAnnotations {
|
|||
export enum patAnnotations {
|
||||
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 { repeater } from "./inputs/repeater";
|
||||
import { roleSelect } from "./inputs/role-select";
|
||||
import { secret } from "./inputs/secret";
|
||||
import { singlePageSelect } from "./inputs/singlePage-select";
|
||||
import { tagCheckbox } from "./inputs/tag-checkbox";
|
||||
import { tagSelect } from "./inputs/tag-select";
|
||||
|
@ -42,26 +43,27 @@ const config: DefaultConfigOptions = {
|
|||
autoScrollToErrors,
|
||||
],
|
||||
inputs: {
|
||||
list,
|
||||
form,
|
||||
password,
|
||||
group,
|
||||
nativeGroup,
|
||||
attachment,
|
||||
code,
|
||||
repeater,
|
||||
menuCheckbox,
|
||||
menuRadio,
|
||||
menuItemSelect,
|
||||
postSelect,
|
||||
categorySelect,
|
||||
tagSelect,
|
||||
singlePageSelect,
|
||||
categoryCheckbox,
|
||||
tagCheckbox,
|
||||
roleSelect,
|
||||
attachmentPolicySelect,
|
||||
attachmentGroupSelect,
|
||||
attachmentPolicySelect,
|
||||
categoryCheckbox,
|
||||
categorySelect,
|
||||
code,
|
||||
form,
|
||||
group,
|
||||
list,
|
||||
menuCheckbox,
|
||||
menuItemSelect,
|
||||
menuRadio,
|
||||
nativeGroup,
|
||||
password,
|
||||
postSelect,
|
||||
repeater,
|
||||
roleSelect,
|
||||
secret,
|
||||
singlePageSelect,
|
||||
tagCheckbox,
|
||||
tagSelect,
|
||||
verificationForm,
|
||||
},
|
||||
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":
|
||||
"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;
|
||||
|
|
|
@ -428,7 +428,10 @@ core:
|
|||
prevent_parent_post_cascade_query: >-
|
||||
Prevent parent category from including this category and its
|
||||
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:
|
||||
title: Pages
|
||||
actions:
|
||||
|
@ -1644,6 +1647,27 @@ core:
|
|||
no_action_defined: "{label} interface not defined"
|
||||
verify_success: "{label} successful"
|
||||
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:
|
||||
buttons:
|
||||
save: Save
|
||||
|
|
|
@ -1567,6 +1567,24 @@ core:
|
|||
no_action_defined: 未定义{label}接口
|
||||
verify_success: "{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:
|
||||
buttons:
|
||||
save: 保存
|
||||
|
|
|
@ -1524,6 +1524,24 @@ core:
|
|||
no_action_defined: 未定義{label}介面
|
||||
verify_success: "{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:
|
||||
buttons:
|
||||
save: 保存
|
||||
|
|
Loading…
Reference in New Issue