mirror of https://github.com/halo-dev/halo
Add external asset transfer for editor attachments (#7687)
#### What type of PR is this? /area ui /area editor /kind feature /milestone 2.21.x #### What this PR does / why we need it: Support transfer external assets in the editor to the attachment library. Currently, it supports individual images, videos, and audio files. <img width="845" height="167" alt="image" src="https://github.com/user-attachments/assets/930c6207-60f5-491a-afbd-c3f75b0d76a6" /> in progress: - [ ] Batch transferring of all external assets. #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2335 #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 支持转存编辑器中的外部资源到附件库 ```pull/7700/head
parent
7d51f38d96
commit
3105c53b6f
|
@ -1,4 +1,10 @@
|
||||||
|
import { useGlobalInfoStore } from "@/stores/global-info";
|
||||||
|
import { ucApiClient } from "@halo-dev/api-client";
|
||||||
|
import { Toast } from "@halo-dev/components";
|
||||||
import type { AttachmentLike } from "@halo-dev/console-shared";
|
import type { AttachmentLike } from "@halo-dev/console-shared";
|
||||||
|
import { computed, ref, type Ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import type { AttachmentAttr } from "../utils/attachment";
|
||||||
|
|
||||||
interface useAttachmentSelectReturn {
|
interface useAttachmentSelectReturn {
|
||||||
onAttachmentSelect: (attachments: AttachmentLike[]) => void;
|
onAttachmentSelect: (attachments: AttachmentLike[]) => void;
|
||||||
|
@ -24,3 +30,56 @@ export function useAttachmentSelect(): useAttachmentSelectReturn {
|
||||||
attachmentResult,
|
attachmentResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useExternalAssetsTransfer(
|
||||||
|
src: Ref<string | undefined>,
|
||||||
|
callback: (attachment: AttachmentAttr) => void
|
||||||
|
) {
|
||||||
|
const { globalInfo } = useGlobalInfoStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isExternalAsset = computed(() => {
|
||||||
|
if (src.value?.startsWith("/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalInfo?.externalUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !src.value?.startsWith(globalInfo?.externalUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferring = ref(false);
|
||||||
|
|
||||||
|
async function handleTransfer() {
|
||||||
|
if (!src.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transferring.value = true;
|
||||||
|
|
||||||
|
const { data } =
|
||||||
|
await ucApiClient.storage.attachment.externalTransferAttachment1({
|
||||||
|
ucUploadFromUrlRequest: {
|
||||||
|
url: src.value,
|
||||||
|
},
|
||||||
|
waitForPermalink: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
callback({
|
||||||
|
url: data.status?.permalink,
|
||||||
|
name: data.spec.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(t("core.common.toast.save_success"));
|
||||||
|
|
||||||
|
transferring.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isExternalAsset,
|
||||||
|
transferring,
|
||||||
|
handleTransfer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||||
import { VButton } from "@halo-dev/components";
|
import { VButton } from "@halo-dev/components";
|
||||||
import type { NodeViewProps } from "@halo-dev/richtext-editor";
|
import type { NodeViewProps } from "@halo-dev/richtext-editor";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import RiFileMusicLine from "~icons/ri/file-music-line";
|
import RiFileMusicLine from "~icons/ri/file-music-line";
|
||||||
import { EditorLinkObtain } from "../../components";
|
import { EditorLinkObtain } from "../../components";
|
||||||
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
||||||
|
import { useExternalAssetsTransfer } from "../../composables/use-attachment";
|
||||||
import type { AttachmentAttr } from "../../utils/attachment";
|
import type { AttachmentAttr } from "../../utils/attachment";
|
||||||
|
|
||||||
const props = defineProps<NodeViewProps>();
|
const props = defineProps<NodeViewProps>();
|
||||||
|
@ -64,6 +66,9 @@ const handleResetInit = () => {
|
||||||
file: undefined,
|
file: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { isExternalAsset, transferring, handleTransfer } =
|
||||||
|
useExternalAssetsTransfer(src, handleSetExternalLink);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -92,8 +97,28 @@ const handleResetInit = () => {
|
||||||
></audio>
|
></audio>
|
||||||
<div
|
<div
|
||||||
v-if="src"
|
v-if="src"
|
||||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end gap-2 bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||||
>
|
>
|
||||||
|
<HasPermission :permissions="['uc:attachments:manage']">
|
||||||
|
<VButton
|
||||||
|
v-if="isExternalAsset"
|
||||||
|
v-tooltip="
|
||||||
|
$t(
|
||||||
|
'core.components.default_editor.extensions.upload.operations.transfer.tooltip'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:loading="transferring"
|
||||||
|
size="sm"
|
||||||
|
ghost
|
||||||
|
@click="handleTransfer"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"core.components.default_editor.extensions.upload.operations.transfer.button"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</VButton>
|
||||||
|
</HasPermission>
|
||||||
<VButton size="xs" type="secondary" @click="handleResetInit">
|
<VButton size="xs" type="secondary" @click="handleResetInit">
|
||||||
{{
|
{{
|
||||||
$t(
|
$t(
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||||
import { IconImageAddLine, VButton } from "@halo-dev/components";
|
import { IconImageAddLine, VButton } from "@halo-dev/components";
|
||||||
import { type NodeViewProps } from "@halo-dev/richtext-editor";
|
import { type NodeViewProps } from "@halo-dev/richtext-editor";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { EditorLinkObtain } from "../../components";
|
import { EditorLinkObtain } from "../../components";
|
||||||
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
||||||
|
import { useExternalAssetsTransfer } from "../../composables/use-attachment";
|
||||||
import { type AttachmentAttr } from "../../utils/attachment";
|
import { type AttachmentAttr } from "../../utils/attachment";
|
||||||
import { fileToBase64 } from "../../utils/upload";
|
import { fileToBase64 } from "../../utils/upload";
|
||||||
import Image from "./index";
|
import Image from "./index";
|
||||||
|
@ -143,6 +145,9 @@ onMounted(() => {
|
||||||
document.documentElement.removeEventListener("mouseup", stopDrag, false);
|
document.documentElement.removeEventListener("mouseup", stopDrag, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isExternalAsset, transferring, handleTransfer } =
|
||||||
|
useExternalAssetsTransfer(src, handleSetExternalLink);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -171,8 +176,29 @@ onMounted(() => {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="src"
|
v-if="src"
|
||||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end gap-2 bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||||
>
|
>
|
||||||
|
<HasPermission :permissions="['uc:attachments:manage']">
|
||||||
|
<VButton
|
||||||
|
v-if="isExternalAsset"
|
||||||
|
v-tooltip="
|
||||||
|
$t(
|
||||||
|
'core.components.default_editor.extensions.upload.operations.transfer.tooltip'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:loading="transferring"
|
||||||
|
size="sm"
|
||||||
|
ghost
|
||||||
|
@click="handleTransfer"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"core.components.default_editor.extensions.upload.operations.transfer.button"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</VButton>
|
||||||
|
</HasPermission>
|
||||||
|
|
||||||
<VButton size="sm" type="secondary" @click="handleResetInit">
|
<VButton size="sm" type="secondary" @click="handleResetInit">
|
||||||
{{
|
{{
|
||||||
$t(
|
$t(
|
||||||
|
|
|
@ -1,12 +1,19 @@
|
||||||
|
import { i18n } from "@/locales";
|
||||||
|
import { Dialog, Toast } from "@halo-dev/components";
|
||||||
import {
|
import {
|
||||||
CoreEditor,
|
CoreEditor,
|
||||||
Extension,
|
Extension,
|
||||||
Plugin,
|
Plugin,
|
||||||
PluginKey,
|
PluginKey,
|
||||||
|
PMNode,
|
||||||
|
Slice,
|
||||||
} from "@halo-dev/richtext-editor";
|
} from "@halo-dev/richtext-editor";
|
||||||
|
import { UiExtensionAudio, UiExtensionImage, UiExtensionVideo } from "..";
|
||||||
import {
|
import {
|
||||||
|
batchUploadExternalLink,
|
||||||
containsFileClipboardIdentifier,
|
containsFileClipboardIdentifier,
|
||||||
handleFileEvent,
|
handleFileEvent,
|
||||||
|
isExternalAsset,
|
||||||
} from "../../utils/upload";
|
} from "../../utils/upload";
|
||||||
|
|
||||||
export const Upload = Extension.create({
|
export const Upload = Extension.create({
|
||||||
|
@ -19,7 +26,7 @@ export const Upload = Extension.create({
|
||||||
new Plugin({
|
new Plugin({
|
||||||
key: new PluginKey("upload"),
|
key: new PluginKey("upload"),
|
||||||
props: {
|
props: {
|
||||||
handlePaste: (view, event: ClipboardEvent) => {
|
handlePaste: (view, event: ClipboardEvent, slice: Slice) => {
|
||||||
if (view.props.editable && !view.props.editable(view.state)) {
|
if (view.props.editable && !view.props.editable(view.state)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -28,6 +35,25 @@ export const Upload = Extension.create({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const externalNodes = getAllExternalNodes(slice);
|
||||||
|
if (externalNodes.length > 0) {
|
||||||
|
Dialog.info({
|
||||||
|
title: i18n.global.t("core.common.text.tip"),
|
||||||
|
description: i18n.global.t(
|
||||||
|
"core.components.default_editor.extensions.upload.operations.transfer_in_batch.description"
|
||||||
|
),
|
||||||
|
confirmText: i18n.global.t("core.common.buttons.confirm"),
|
||||||
|
cancelText: i18n.global.t("core.common.buttons.cancel"),
|
||||||
|
async onConfirm() {
|
||||||
|
await batchUploadExternalLink(editor, externalNodes);
|
||||||
|
|
||||||
|
Toast.success(
|
||||||
|
i18n.global.t("core.common.toast.save_success")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const types = event.clipboardData.types;
|
const types = event.clipboardData.types;
|
||||||
// Only process when a single file is pasted.
|
// Only process when a single file is pasted.
|
||||||
if (types.length > 1) {
|
if (types.length > 1) {
|
||||||
|
@ -87,4 +113,33 @@ export const Upload = Extension.create({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const checkExternalLinkNodeTypes = [
|
||||||
|
UiExtensionAudio.name,
|
||||||
|
UiExtensionVideo.name,
|
||||||
|
UiExtensionImage.name,
|
||||||
|
];
|
||||||
|
export function getAllExternalNodes(
|
||||||
|
slice: Slice
|
||||||
|
): { node: PMNode; pos: number; index: number; parent: PMNode | null }[] {
|
||||||
|
const externalNodes: {
|
||||||
|
node: PMNode;
|
||||||
|
pos: number;
|
||||||
|
index: number;
|
||||||
|
parent: PMNode | null;
|
||||||
|
}[] = [];
|
||||||
|
slice.content.descendants((node, pos, parent, index) => {
|
||||||
|
if (checkExternalLinkNodeTypes.includes(node.type.name)) {
|
||||||
|
if (isExternalAsset(node.attrs.src)) {
|
||||||
|
externalNodes.push({
|
||||||
|
node,
|
||||||
|
pos,
|
||||||
|
parent,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return externalNodes;
|
||||||
|
}
|
||||||
|
|
||||||
export default Upload;
|
export default Upload;
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||||
import { VButton } from "@halo-dev/components";
|
import { VButton } from "@halo-dev/components";
|
||||||
import type { NodeViewProps } from "@halo-dev/richtext-editor";
|
import type { NodeViewProps } from "@halo-dev/richtext-editor";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import RiVideoAddLine from "~icons/ri/video-add-line";
|
import RiVideoAddLine from "~icons/ri/video-add-line";
|
||||||
import { EditorLinkObtain } from "../../components";
|
import { EditorLinkObtain } from "../../components";
|
||||||
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
import InlineBlockBox from "../../components/InlineBlockBox.vue";
|
||||||
|
import { useExternalAssetsTransfer } from "../../composables/use-attachment";
|
||||||
import type { AttachmentAttr } from "../../utils/attachment";
|
import type { AttachmentAttr } from "../../utils/attachment";
|
||||||
|
|
||||||
const props = defineProps<NodeViewProps>();
|
const props = defineProps<NodeViewProps>();
|
||||||
|
@ -68,6 +70,9 @@ const handleResetInit = () => {
|
||||||
file: undefined,
|
file: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { isExternalAsset, transferring, handleTransfer } =
|
||||||
|
useExternalAssetsTransfer(src, handleSetExternalLink);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -97,8 +102,28 @@ const handleResetInit = () => {
|
||||||
></video>
|
></video>
|
||||||
<div
|
<div
|
||||||
v-if="src"
|
v-if="src"
|
||||||
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
class="absolute left-0 top-0 hidden h-1/4 w-full cursor-pointer justify-end gap-2 bg-gradient-to-b from-gray-300 to-transparent p-2 ease-in-out group-hover:flex"
|
||||||
>
|
>
|
||||||
|
<HasPermission :permissions="['uc:attachments:manage']">
|
||||||
|
<VButton
|
||||||
|
v-if="isExternalAsset"
|
||||||
|
v-tooltip="
|
||||||
|
$t(
|
||||||
|
'core.components.default_editor.extensions.upload.operations.transfer.tooltip'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:loading="transferring"
|
||||||
|
size="sm"
|
||||||
|
ghost
|
||||||
|
@click="handleTransfer"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"core.components.default_editor.extensions.upload.operations.transfer.button"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</VButton>
|
||||||
|
</HasPermission>
|
||||||
<VButton size="sm" type="secondary" @click="handleResetInit">
|
<VButton size="sm" type="secondary" @click="handleResetInit">
|
||||||
{{
|
{{
|
||||||
$t(
|
$t(
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
// image drag and paste upload
|
// image drag and paste upload
|
||||||
import { usePermission } from "@/utils/permission";
|
import { usePermission } from "@/utils/permission";
|
||||||
import type { Attachment } from "@halo-dev/api-client";
|
import { ucApiClient, type Attachment } from "@halo-dev/api-client";
|
||||||
import { CoreEditor } from "@halo-dev/richtext-editor";
|
import { CoreEditor, PMNode } from "@halo-dev/richtext-editor";
|
||||||
import type { AxiosRequestConfig } from "axios";
|
import type { AxiosRequestConfig } from "axios";
|
||||||
|
import { chunk } from "lodash-es";
|
||||||
import ExtensionAudio from "../extensions/audio";
|
import ExtensionAudio from "../extensions/audio";
|
||||||
import Image from "../extensions/image";
|
import Image from "../extensions/image";
|
||||||
import ExtensionVideo from "../extensions/video";
|
import ExtensionVideo from "../extensions/video";
|
||||||
|
@ -147,3 +148,65 @@ export function containsFileClipboardIdentifier(types: readonly string[]) {
|
||||||
const fileTypes = ["files", "application/x-moz-file", "public.file-url"];
|
const fileTypes = ["files", "application/x-moz-file", "public.file-url"];
|
||||||
return types.some((type) => fileTypes.includes(type.toLowerCase()));
|
return types.some((type) => fileTypes.includes(type.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchUploadExternalLink(
|
||||||
|
editor: CoreEditor,
|
||||||
|
nodes: { node: PMNode; pos: number; index: number; parent: PMNode | null }[]
|
||||||
|
) {
|
||||||
|
const chunks = chunk(nodes, 5);
|
||||||
|
|
||||||
|
for (const chunkNodes of chunks) {
|
||||||
|
await Promise.all(
|
||||||
|
chunkNodes.map((node) => uploadExternalLink(editor, node))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadExternalLink(
|
||||||
|
editor: CoreEditor,
|
||||||
|
nodeWithPos: {
|
||||||
|
node: PMNode;
|
||||||
|
pos: number;
|
||||||
|
index: number;
|
||||||
|
parent: PMNode | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { node, pos } = nodeWithPos;
|
||||||
|
const { src } = node.attrs;
|
||||||
|
|
||||||
|
if (!isExternalAsset(src)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } =
|
||||||
|
await ucApiClient.storage.attachment.externalTransferAttachment1({
|
||||||
|
ucUploadFromUrlRequest: {
|
||||||
|
url: src,
|
||||||
|
},
|
||||||
|
waitForPermalink: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = data.status?.permalink;
|
||||||
|
const name = data.spec.displayName;
|
||||||
|
const tr = editor.view.state.tr;
|
||||||
|
tr.setNodeMarkup(pos, node.type, {
|
||||||
|
...node.attrs,
|
||||||
|
src: url,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
editor.view.dispatch(tr);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload external link:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExternalAsset(src: string) {
|
||||||
|
if (src?.startsWith("/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentOrigin = window.location.origin;
|
||||||
|
|
||||||
|
return !src?.startsWith(currentOrigin);
|
||||||
|
}
|
||||||
|
|
|
@ -117,7 +117,7 @@ core:
|
||||||
fields:
|
fields:
|
||||||
schedule_publish:
|
schedule_publish:
|
||||||
tooltip: Schedule publish
|
tooltip: Schedule publish
|
||||||
comments-with-pending: ({count} pending comments)
|
comments-with-pending: " ({count} pending comments)"
|
||||||
settings:
|
settings:
|
||||||
fields:
|
fields:
|
||||||
publish_time:
|
publish_time:
|
||||||
|
@ -710,6 +710,11 @@ core:
|
||||||
operations:
|
operations:
|
||||||
replace:
|
replace:
|
||||||
button: Replace
|
button: Replace
|
||||||
|
transfer:
|
||||||
|
button: Save locally
|
||||||
|
tooltip: This resource is detected as an external resource. Click this button to save it to the attachment library.
|
||||||
|
transfer_in_batch:
|
||||||
|
description: External link detected. Would you like to automatically upload it to the attachment library?
|
||||||
toolbox:
|
toolbox:
|
||||||
show_hide_sidebar: Show/Hide Sidebar
|
show_hide_sidebar: Show/Hide Sidebar
|
||||||
title_placeholder: Please enter the title
|
title_placeholder: Please enter the title
|
||||||
|
|
|
@ -1800,6 +1800,15 @@ core:
|
||||||
operations:
|
operations:
|
||||||
replace:
|
replace:
|
||||||
button: Replace
|
button: Replace
|
||||||
|
transfer:
|
||||||
|
button: Save locally
|
||||||
|
tooltip: >-
|
||||||
|
This resource is detected as an external resource. Click this
|
||||||
|
button to save it to the attachment library.
|
||||||
|
transfer_in_batch:
|
||||||
|
description: >-
|
||||||
|
External link detected. Would you like to automatically upload
|
||||||
|
it to the attachment library?
|
||||||
toolbox:
|
toolbox:
|
||||||
attachment: Attachment
|
attachment: Attachment
|
||||||
show_hide_sidebar: Show/Hide Sidebar
|
show_hide_sidebar: Show/Hide Sidebar
|
||||||
|
|
|
@ -1665,6 +1665,11 @@ core:
|
||||||
operations:
|
operations:
|
||||||
replace:
|
replace:
|
||||||
button: 替换
|
button: 替换
|
||||||
|
transfer:
|
||||||
|
button: 转存
|
||||||
|
tooltip: 检测到此资源为外部资源,点击此按钮以转存到附件库
|
||||||
|
transfer_in_batch:
|
||||||
|
description: 检测到具有外部链接,是否需要自动上传到附件库?
|
||||||
toolbox:
|
toolbox:
|
||||||
attachment: 选择附件
|
attachment: 选择附件
|
||||||
show_hide_sidebar: 显示 / 隐藏侧边栏
|
show_hide_sidebar: 显示 / 隐藏侧边栏
|
||||||
|
|
|
@ -1650,6 +1650,11 @@ core:
|
||||||
operations:
|
operations:
|
||||||
replace:
|
replace:
|
||||||
button: 替換
|
button: 替換
|
||||||
|
transfer:
|
||||||
|
button: 轉存
|
||||||
|
tooltip: 偵測到此資源為外部資源,點擊此按鈕以轉存到附件庫
|
||||||
|
transfer_in_batch:
|
||||||
|
description: 檢測到具有外部連結,是否需要自動上傳到附件庫?
|
||||||
toolbox:
|
toolbox:
|
||||||
attachment: 選擇附件
|
attachment: 選擇附件
|
||||||
show_hide_sidebar: 顯示 / 隱藏側邊欄
|
show_hide_sidebar: 顯示 / 隱藏側邊欄
|
||||||
|
|
Loading…
Reference in New Issue