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
Ryan Wang 2024-06-27 18:08:55 +08:00 committed by GitHub
parent 0f6722a37e
commit 9d478eecf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 858 additions and 29 deletions

View File

@ -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 是一个集合类型的输入组件,可以让使用者可视化的操作集合。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import { createSection } from "@formkit/inputs";
export const SecretSection = createSection("SecretSection", () => ({
$cmp: "SecretSelect",
props: {
context: "$node.context",
},
}));

View File

@ -0,0 +1,4 @@
export interface SecretFormState {
description?: string;
stringDataArray: { key: string; value: string }[];
}

View File

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

View File

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

View File

@ -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: 保存

View File

@ -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: 保存