feat: add support for remote URL attachment downloads (#7602)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.21.x

#### What this PR does / why we need it:

Add support for remote URL attachment downloads

<img width="1031" alt="image" src="https://github.com/user-attachments/assets/f85eee2f-a40b-49ff-9ced-31136f59e67c" />

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/7017

#### Does this PR introduce a user-facing change?

```release-note
支持通过远程地址下载到附件库
```
pull/7613/head
Ryan Wang 2025-07-04 12:37:40 +08:00 committed by GitHub
parent 79226998d3
commit a4a418b22e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 337 additions and 92 deletions

View File

@ -15420,7 +15420,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadFromUrlRequest"
"$ref": "#/components/schemas/UcUploadFromUrlRequest"
}
}
},
@ -20583,12 +20583,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},
@ -22355,12 +22355,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},
@ -23386,6 +23386,22 @@
}
}
},
"UcUploadFromUrlRequest": {
"required": [
"url"
],
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "Custom file name"
},
"url": {
"type": "string",
"format": "url"
}
}
},
"UcUploadRequest": {
"required": [
"file"
@ -23445,12 +23461,22 @@
},
"UploadFromUrlRequest": {
"required": [
"policyName",
"url"
],
"type": "object",
"properties": {
"filename": {
"type": "string"
"type": "string",
"description": "Custom file name"
},
"groupName": {
"type": "string",
"description": "The name of the group to which the attachment belongs"
},
"policyName": {
"type": "string",
"description": "Storage policy name"
},
"url": {
"type": "string",

View File

@ -5471,12 +5471,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},
@ -6007,12 +6007,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},
@ -6468,13 +6468,16 @@
"type": "object",
"properties": {
"filename": {
"type": "string"
"type": "string",
"description": "Custom file name"
},
"groupName": {
"type": "string"
"type": "string",
"description": "The name of the group to which the attachment belongs"
},
"policyName": {
"type": "string"
"type": "string",
"description": "Storage policy name"
},
"url": {
"type": "string",

View File

@ -12903,12 +12903,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},
@ -14281,12 +14281,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},

View File

@ -2609,12 +2609,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},
@ -3183,12 +3183,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},

View File

@ -1420,7 +1420,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadFromUrlRequest"
"$ref": "#/components/schemas/UcUploadFromUrlRequest"
}
}
},
@ -2470,12 +2470,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},
@ -2911,6 +2911,22 @@
}
}
},
"UcUploadFromUrlRequest": {
"required": [
"url"
],
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "Custom file name"
},
"url": {
"type": "string",
"format": "url"
}
}
},
"UcUploadRequest": {
"required": [
"file"
@ -2944,21 +2960,6 @@
}
}
},
"UploadFromUrlRequest": {
"required": [
"url"
],
"type": "object",
"properties": {
"filename": {
"type": "string"
},
"url": {
"type": "string",
"format": "url"
}
}
},
"UserConnection": {
"required": [
"apiVersion",

View File

@ -5,12 +5,10 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Objects;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;

View File

@ -73,7 +73,7 @@ public class FileTypeDetectUtils {
/**
* <p>Recommend to use this method to verify whether the file extension matches the file type
* after matching the file type to avoid XSS attacks such as bypassing detection by polyglot
* file</p>
* file.</p>
*
* @param mimeType file mime type,such as "image/png"
* @param fileName file name,such as "test.png"

View File

@ -1,12 +1,12 @@
package run.halo.app.theme.router;
import java.util.Objects;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import run.halo.app.extension.ListResult;
import run.halo.app.infra.utils.PathUtils;
import java.util.Objects;
/**
* A utility class for template page url.

View File

@ -118,9 +118,11 @@ public class AttachmentEndpoint implements CustomEndpoint {
}
public record UploadFromUrlRequest(@Schema(requiredMode = REQUIRED) URL url,
@Schema(requiredMode = REQUIRED) String policyName,
String groupName,
String filename) {
@Schema(requiredMode = REQUIRED, description = "Storage "
+ "policy name") String policyName,
@Schema(description = "The name of the group to which the "
+ "attachment belongs") String groupName,
@Schema(description = "Custom file name") String filename) {
public UploadFromUrlRequest {
if (Objects.isNull(url)) {
throw new ServerWebInputException("Required url is missing.");

View File

@ -333,8 +333,9 @@ public class UcAttachmentEndpoint implements CustomEndpoint {
return GroupVersion.parseAPIVersion("uc.api.storage.halo.run/v1alpha1");
}
@Schema(name = "UcUploadFromUrlRequest")
public record UploadFromUrlRequest(@Schema(requiredMode = REQUIRED) URL url,
String filename) {
@Schema(description = "Custom file name") String filename) {
public UploadFromUrlRequest {
if (Objects.isNull(url)) {
throw new ServerWebInputException("Required url is missing.");

View File

@ -147,14 +147,13 @@ public class DefaultAttachmentService implements AttachmentService {
AtomicReference<String> fileNameRef = new AtomicReference<>(filename);
Mono<Flux<DataBuffer>> contentMono = dataBufferFetcher.head(uri)
.map(response -> {
var httpHeaders = response.getHeaders();
.map(httpHeaders -> {
if (!StringUtils.hasText(fileNameRef.get())) {
fileNameRef.set(getExternalUrlFilename(uri, httpHeaders));
}
MediaType contentType = httpHeaders.getContentType();
mediaTypeRef.set(contentType);
return response;
return httpHeaders;
})
.map(response -> dataBufferFetcher.fetch(uri));

View File

@ -2,10 +2,11 @@ package run.halo.app.infra;
import java.net.URI;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -21,6 +22,8 @@ import reactor.netty.http.client.HttpClient;
public class DefaultReactiveUrlDataBufferFetcher implements ReactiveUrlDataBufferFetcher {
private final HttpClient httpClient = HttpClient.create()
.followRedirect(true);
private final ContentLengthFetcher contentLengthFetcher = new ContentLengthFetcher();
private final WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
@ -35,10 +38,32 @@ public class DefaultReactiveUrlDataBufferFetcher implements ReactiveUrlDataBuffe
}
@Override
public Mono<ResponseEntity<Void>> head(URI uri) {
return webClient.head()
.uri(uri)
.retrieve()
.toBodilessEntity();
public Mono<HttpHeaders> head(URI uri) {
return contentLengthFetcher.fetchContentLength(uri);
}
static class ContentLengthFetcher {
private final WebClient webClient;
ContentLengthFetcher() {
this.webClient = WebClient.builder()
.exchangeStrategies(ExchangeStrategies.builder()
.codecs(config -> config.defaultCodecs().maxInMemorySize(1))
.build())
.build();
}
Mono<HttpHeaders> fetchContentLength(URI url) {
return webClient.get()
.uri(url)
.exchangeToMono(response -> {
HttpHeaders headers = response.headers().asHttpHeaders();
return response.bodyToMono(byte[].class)
.onErrorResume(ex -> Mono.empty())
.thenReturn(headers);
});
}
}
}

View File

@ -2,7 +2,7 @@ package run.halo.app.infra;
import java.net.URI;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpHeaders;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -28,5 +28,5 @@ public interface ReactiveUrlDataBufferFetcher {
* @param uri uri to fetch
* @return response entity
*/
Mono<ResponseEntity<Void>> head(URI uri);
Mono<HttpHeaders> head(URI uri);
}

View File

@ -17,6 +17,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@ -303,10 +304,11 @@ class AttachmentEndpointTest {
attachment.setMetadata(metadata);
ResponseEntity<Void> response = new ResponseEntity<>(HttpStatusCode.valueOf(200));
HttpHeaders headers = response.getHeaders();
DataBuffer dataBuffer = mock(DataBuffer.class);
when(handler.upload(any())).thenReturn(Mono.just(attachment));
when(dataBufferFetcher.head(any())).thenReturn(Mono.just(response));
when(dataBufferFetcher.head(any())).thenReturn(Mono.just(headers));
when(dataBufferFetcher.fetch(any())).thenReturn(Flux.just(dataBuffer));
when(extensionGetter.getExtensions(AttachmentHandler.class))
.thenReturn(Flux.just(handler));

View File

@ -6,6 +6,8 @@ import {
VDropdown,
VDropdownItem,
VModal,
VTabItem,
VTabs,
} from "@halo-dev/components";
import { useLocalStorage } from "@vueuse/core";
import { onMounted, ref } from "vue";
@ -18,6 +20,7 @@ import AttachmentGroupBadge from "./AttachmentGroupBadge.vue";
import AttachmentGroupEditingModal from "./AttachmentGroupEditingModal.vue";
import AttachmentPolicyBadge from "./AttachmentPolicyBadge.vue";
import AttachmentPolicyEditingModal from "./AttachmentPolicyEditingModal.vue";
import UploadFromUrl from "./UploadFromUrl.vue";
const emit = defineEmits<{
(event: "close"): void;
@ -62,6 +65,8 @@ const onGroupEditingModalClose = async () => {
await handleFetchGroups();
groupEditingModal.value = false;
};
const activeTab = ref("upload");
</script>
<template>
@ -156,22 +161,43 @@ const onGroupEditingModalClose = async () => {
</template>
</AttachmentGroupBadge>
</div>
<UppyUpload
endpoint="/apis/api.console.halo.run/v1alpha1/attachments/upload"
:disabled="!selectedPolicyName"
:meta="{
policyName: selectedPolicyName,
groupName: selectedGroupName,
}"
width="100%"
:allowed-meta-fields="['policyName', 'groupName']"
:note="
selectedPolicyName
? ''
: $t('core.attachment.upload_modal.filters.policy.not_select')
"
:done-button-handler="() => modal?.close()"
/>
<div class="mb-3">
<VTabs v-model:active-id="activeTab" type="outline">
<VTabItem
id="upload"
:label="
$t('core.attachment.upload_modal.upload_options.local_upload')
"
>
<UppyUpload
endpoint="/apis/api.console.halo.run/v1alpha1/attachments/upload"
:disabled="!selectedPolicyName"
:meta="{
policyName: selectedPolicyName,
groupName: selectedGroupName,
}"
width="100%"
:allowed-meta-fields="['policyName', 'groupName']"
:note="
selectedPolicyName
? ''
: $t('core.attachment.upload_modal.filters.policy.not_select')
"
:done-button-handler="() => modal?.close()"
/>
</VTabItem>
<VTabItem
id="download"
:label="$t('core.attachment.upload_modal.upload_options.download')"
>
<UploadFromUrl
:policy-name="selectedPolicyName"
:group-name="selectedGroupName"
/>
</VTabItem>
</VTabs>
</div>
</div>
</VModal>

View File

@ -0,0 +1,75 @@
<script lang="ts" setup>
import { setFocus } from "@/formkit/utils/focus";
import { reset } from "@formkit/core";
import { consoleApiClient } from "@halo-dev/api-client";
import { Toast, VButton } from "@halo-dev/components";
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
policyName: string;
groupName: string;
}>(),
{}
);
onMounted(() => {
setFocus("url");
});
const downloading = ref(false);
async function onSubmit(data: { url: string }) {
try {
downloading.value = true;
await consoleApiClient.storage.attachment.externalTransferAttachment({
uploadFromUrlRequest: {
url: data.url,
policyName: props.policyName,
groupName: props.groupName,
},
});
Toast.success(
t("core.attachment.upload_modal.download_form.toast.success")
);
reset("url");
} catch (error) {
return error;
} finally {
downloading.value = false;
}
}
</script>
<template>
<FormKit
id="upload-from-url"
type="form"
name="upload-from-url"
:config="{ validationVisibility: 'submit' }"
@submit="onSubmit"
>
<FormKit
id="url"
type="url"
name="url"
:label="$t('core.attachment.upload_modal.download_form.fields.url.label')"
:validation="[['required'], ['url']]"
/>
</FormKit>
<div class="mt-4">
<VButton
type="secondary"
:loading="downloading"
@click="$formkit.submit('upload-from-url')"
>
{{ $t("core.common.buttons.download") }}
</VButton>
</div>
</template>

View File

@ -322,6 +322,7 @@ models/thumbnail.ts
models/totp-auth-link-response.ts
models/totp-request.ts
models/two-factor-auth-settings.ts
models/uc-upload-from-url-request.ts
models/uc-upload-request-form-data.ts
models/upgrade-from-uri-request.ts
models/upload-from-url-request.ts

View File

@ -26,9 +26,9 @@ import { Attachment } from '../models';
// @ts-ignore
import { AttachmentList } from '../models';
// @ts-ignore
import { UcUploadRequestFormData } from '../models';
import { UcUploadFromUrlRequest } from '../models';
// @ts-ignore
import { UploadFromUrlRequest } from '../models';
import { UcUploadRequestFormData } from '../models';
/**
* AttachmentV1alpha1UcApi - axios parameter creator
* @export
@ -100,14 +100,14 @@ export const AttachmentV1alpha1UcApiAxiosParamCreator = function (configuration?
},
/**
* Upload attachment from the given URL.
* @param {UploadFromUrlRequest} uploadFromUrlRequest
* @param {UcUploadFromUrlRequest} ucUploadFromUrlRequest
* @param {boolean} [waitForPermalink] Wait for permalink.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
externalTransferAttachment1: async (uploadFromUrlRequest: UploadFromUrlRequest, waitForPermalink?: boolean, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'uploadFromUrlRequest' is not null or undefined
assertParamExists('externalTransferAttachment1', 'uploadFromUrlRequest', uploadFromUrlRequest)
externalTransferAttachment1: async (ucUploadFromUrlRequest: UcUploadFromUrlRequest, waitForPermalink?: boolean, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'ucUploadFromUrlRequest' is not null or undefined
assertParamExists('externalTransferAttachment1', 'ucUploadFromUrlRequest', ucUploadFromUrlRequest)
const localVarPath = `/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload-from-url`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -139,7 +139,7 @@ export const AttachmentV1alpha1UcApiAxiosParamCreator = function (configuration?
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(uploadFromUrlRequest, localVarRequestOptions, configuration)
localVarRequestOptions.data = serializeDataIfNeeded(ucUploadFromUrlRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
@ -303,13 +303,13 @@ export const AttachmentV1alpha1UcApiFp = function(configuration?: Configuration)
},
/**
* Upload attachment from the given URL.
* @param {UploadFromUrlRequest} uploadFromUrlRequest
* @param {UcUploadFromUrlRequest} ucUploadFromUrlRequest
* @param {boolean} [waitForPermalink] Wait for permalink.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async externalTransferAttachment1(uploadFromUrlRequest: UploadFromUrlRequest, waitForPermalink?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Attachment>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.externalTransferAttachment1(uploadFromUrlRequest, waitForPermalink, options);
async externalTransferAttachment1(ucUploadFromUrlRequest: UcUploadFromUrlRequest, waitForPermalink?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Attachment>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.externalTransferAttachment1(ucUploadFromUrlRequest, waitForPermalink, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AttachmentV1alpha1UcApi.externalTransferAttachment1']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -372,7 +372,7 @@ export const AttachmentV1alpha1UcApiFactory = function (configuration?: Configur
* @throws {RequiredError}
*/
externalTransferAttachment1(requestParameters: AttachmentV1alpha1UcApiExternalTransferAttachment1Request, options?: RawAxiosRequestConfig): AxiosPromise<Attachment> {
return localVarFp.externalTransferAttachment1(requestParameters.uploadFromUrlRequest, requestParameters.waitForPermalink, options).then((request) => request(axios, basePath));
return localVarFp.externalTransferAttachment1(requestParameters.ucUploadFromUrlRequest, requestParameters.waitForPermalink, options).then((request) => request(axios, basePath));
},
/**
* List attachments of the current user uploaded.
@ -438,10 +438,10 @@ export interface AttachmentV1alpha1UcApiCreateAttachmentForPostRequest {
export interface AttachmentV1alpha1UcApiExternalTransferAttachment1Request {
/**
*
* @type {UploadFromUrlRequest}
* @type {UcUploadFromUrlRequest}
* @memberof AttachmentV1alpha1UcApiExternalTransferAttachment1
*/
readonly uploadFromUrlRequest: UploadFromUrlRequest
readonly ucUploadFromUrlRequest: UcUploadFromUrlRequest
/**
* Wait for permalink.
@ -561,7 +561,7 @@ export class AttachmentV1alpha1UcApi extends BaseAPI {
* @memberof AttachmentV1alpha1UcApi
*/
public externalTransferAttachment1(requestParameters: AttachmentV1alpha1UcApiExternalTransferAttachment1Request, options?: RawAxiosRequestConfig) {
return AttachmentV1alpha1UcApiFp(this.configuration).externalTransferAttachment1(requestParameters.uploadFromUrlRequest, requestParameters.waitForPermalink, options).then((request) => request(this.axios, this.basePath));
return AttachmentV1alpha1UcApiFp(this.configuration).externalTransferAttachment1(requestParameters.ucUploadFromUrlRequest, requestParameters.waitForPermalink, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -233,6 +233,7 @@ export * from './thumbnail-spec';
export * from './totp-auth-link-response';
export * from './totp-request';
export * from './two-factor-auth-settings';
export * from './uc-upload-from-url-request';
export * from './uc-upload-request-form-data';
export * from './upgrade-from-uri-request';
export * from './upload-from-url-request';

View File

@ -0,0 +1,36 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.21.0-SNAPSHOT
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface UcUploadFromUrlRequest
*/
export interface UcUploadFromUrlRequest {
/**
* Custom file name
* @type {string}
* @memberof UcUploadFromUrlRequest
*/
'filename'?: string;
/**
*
* @type {string}
* @memberof UcUploadFromUrlRequest
*/
'url': string;
}

View File

@ -21,11 +21,23 @@
*/
export interface UploadFromUrlRequest {
/**
*
* Custom file name
* @type {string}
* @memberof UploadFromUrlRequest
*/
'filename'?: string;
/**
* The name of the group to which the attachment belongs
* @type {string}
* @memberof UploadFromUrlRequest
*/
'groupName'?: string;
/**
* Storage policy name
* @type {string}
* @memberof UploadFromUrlRequest
*/
'policyName': string;
/**
*
* @type {string}

View File

@ -222,6 +222,16 @@ core:
policy_editing_modal:
toast:
policy_name_exists: Storage policy name already exists
upload_modal:
upload_options:
local_upload: Upload
download: Download from url
download_form:
fields:
url:
label: URL
toast:
success: Downloaded successfully
uc_attachment:
empty:
title: There are no attachments.

View File

@ -158,8 +158,8 @@ core:
back:
title: Layout not saved
description: >-
The current layout has not been saved, if you leave, the current layout
will be lost, do you want to continue?
The current layout has not been saved, if you leave, the current
layout will be lost, do you want to continue?
confirm_text: Leave
change_breakpoint:
tips_not_saved: Please save the current layout first
@ -754,6 +754,15 @@ core:
title: No storage policy
description: Before uploading, a new storage policy needs to be created.
not_select: Please select a storage policy first.
upload_options:
local_upload: Upload
download: Download from url
download_form:
fields:
url:
label: URL
toast:
success: Downloaded successfully
select_modal:
title: Select attachment
providers:

View File

@ -711,6 +711,15 @@ core:
title: 没有存储策略
description: 在上传之前,需要新建一个存储策略
not_select: 请先选择存储策略
upload_options:
local_upload: 本地上传
download: 通过链接下载
download_form:
fields:
url:
label: 链接地址
toast:
success: 下载成功
select_modal:
title: 选择附件
providers:

View File

@ -696,6 +696,15 @@ core:
title: 沒有存儲策略
description: 在上傳之前,需要新建一個存儲策略
not_select: 請先選擇存儲策略
upload_options:
local_upload: 本地上傳
download: 通過鏈接下載
download_form:
fields:
url:
label: 鏈接地址
toast:
success: 下載成功
select_modal:
title: 選擇附件
providers: