anduin ревизій цього gist . До ревизії
1 file changed, 551 insertions
FileUploader.md(файл створено)
@@ -0,0 +1,551 @@ | |||
1 | + | 我写了个需要上传文件的页面。但是我觉得上传文件这个需求太常见了,于是我抽了个view component的。 | |
2 | + | ||
3 | + | 我给你看看我的思路和实现。 | |
4 | + | ||
5 | + | 我喜欢使用dropify。 | |
6 | + | ||
7 | + | 为了好用的上传,我打算把上传过程分离。 | |
8 | + | ||
9 | + | 首先有一个真正处理文件的controller: | |
10 | + | ||
11 | + | ```csharp | |
12 | + | namespace Aiursoft.CSTools.Attributes; | |
13 | + | ||
14 | + | public class ValidDomainName : ValidationAttribute | |
15 | + | { | |
16 | + | private readonly string _domainRegex = "^[-a-z0-9_]+$"; | |
17 | + | ||
18 | + | public override bool IsValid(object? value) | |
19 | + | { | |
20 | + | Regex regex = new Regex(this._domainRegex, RegexOptions.Compiled); | |
21 | + | return value is string input && regex.IsMatch(input); | |
22 | + | } | |
23 | + | ||
24 | + | protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) | |
25 | + | { | |
26 | + | return this.IsValid(value) ? ValidationResult.Success : new ValidationResult($"The {validationContext.DisplayName} can only contain numbers, alphabet and underline."); | |
27 | + | } | |
28 | + | } | |
29 | + | ||
30 | + | using Aiursoft.CSTools.Attributes; | |
31 | + | using Aiursoft.Template.Services; | |
32 | + | using Microsoft.AspNetCore.Mvc; | |
33 | + | ||
34 | + | namespace Aiursoft.Template.Controllers; | |
35 | + | ||
36 | + | public class FilesController( | |
37 | + | StorageService storage) : ControllerBase | |
38 | + | { | |
39 | + | [Route("upload/{subfolder}")] | |
40 | + | public async Task<IActionResult> Index([FromRoute] [ValidDomainName] string subfolder) | |
41 | + | { | |
42 | + | if (!ModelState.IsValid) | |
43 | + | { | |
44 | + | return BadRequest(); | |
45 | + | } | |
46 | + | ||
47 | + | // Executing here will let the browser upload the file. | |
48 | + | try | |
49 | + | { | |
50 | + | _ = HttpContext.Request.Form.Files.FirstOrDefault()?.ContentType; | |
51 | + | } | |
52 | + | catch (InvalidOperationException e) | |
53 | + | { | |
54 | + | return BadRequest(e.Message); | |
55 | + | } | |
56 | + | ||
57 | + | if (HttpContext.Request.Form.Files.Count < 1) | |
58 | + | { | |
59 | + | return BadRequest("No file uploaded!"); | |
60 | + | } | |
61 | + | ||
62 | + | var file = HttpContext.Request.Form.Files.First(); | |
63 | + | if (!new ValidFolderName().IsValid(file.FileName)) | |
64 | + | { | |
65 | + | return BadRequest("Invalid file name!"); | |
66 | + | } | |
67 | + | ||
68 | + | var storePath = Path.Combine( | |
69 | + | subfolder, | |
70 | + | DateTime.UtcNow.Year.ToString("D4"), | |
71 | + | DateTime.UtcNow.Month.ToString("D2"), | |
72 | + | DateTime.UtcNow.Day.ToString("D2"), | |
73 | + | file.FileName); | |
74 | + | var relativePath = await storage.Save(storePath, file); | |
75 | + | return Ok(new | |
76 | + | { | |
77 | + | Path = relativePath, | |
78 | + | InternetPath = storage.RelativePathToInternetUrl(relativePath, HttpContext) | |
79 | + | }); | |
80 | + | } | |
81 | + | ||
82 | + | [Route("download/{**folderNames}")] | |
83 | + | public IActionResult Download([FromRoute] string folderNames) | |
84 | + | { | |
85 | + | if (!ModelState.IsValid) | |
86 | + | { | |
87 | + | return BadRequest(); | |
88 | + | } | |
89 | + | ||
90 | + | if (folderNames.Contains("..")) | |
91 | + | { | |
92 | + | return BadRequest("Invalid path!"); | |
93 | + | } | |
94 | + | ||
95 | + | var physicalPath = storage.GetFilePhysicalPath(folderNames); | |
96 | + | var workspaceFullPath = Path.GetFullPath(storage.WorkspaceFolder); | |
97 | + | if (!physicalPath.StartsWith(workspaceFullPath)) | |
98 | + | { | |
99 | + | return BadRequest("Attempted to access a restricted path."); | |
100 | + | } | |
101 | + | if (!System.IO.File.Exists(physicalPath)) | |
102 | + | { | |
103 | + | return NotFound(); | |
104 | + | } | |
105 | + | ||
106 | + | return this.WebFile(physicalPath); | |
107 | + | } | |
108 | + | } | |
109 | + | ||
110 | + | ``` | |
111 | + | ||
112 | + | 其调用存储服务。 | |
113 | + | ||
114 | + | 这个存储服务的设计思路是不把真正的互联网上的 URL 存储到数据库,而是先将文件保存,再把它的相对路径存储到数据库。而真正在前端展示的时候,再使用 `RelativePathToInternetUrl` 方法转换成真正的 URL。 | |
115 | + | ||
116 | + | ```csharp | |
117 | + | using Aiursoft.Scanner.Abstractions; | |
118 | + | ||
119 | + | namespace Aiursoft.Template.Services; | |
120 | + | ||
121 | + | /// <summary> | |
122 | + | /// Represents a service for storing and retrieving files. | |
123 | + | /// </summary> | |
124 | + | public class StorageService(IConfiguration configuration) : ISingletonDependency | |
125 | + | { | |
126 | + | public readonly string WorkspaceFolder = configuration["Storage:Path"]!; | |
127 | + | ||
128 | + | // Async lock. | |
129 | + | private readonly SemaphoreSlim _lock = new(1, 1); | |
130 | + | ||
131 | + | /// <summary> | |
132 | + | /// Saves a file to the storage. | |
133 | + | /// </summary> | |
134 | + | /// <param name="savePath">The path where the file will be saved. The 'savePath' is the path that the user wants to save. Not related to actual disk path.</param> | |
135 | + | /// <param name="file">The file to be saved.</param> | |
136 | + | /// <returns>The actual path where the file is saved relative to the workspace folder.</returns> | |
137 | + | public async Task<string> Save(string savePath, IFormFile file) | |
138 | + | { | |
139 | + | var finalFilePath = Path.Combine(WorkspaceFolder, savePath); | |
140 | + | var finalFolder = Path.GetDirectoryName(finalFilePath); | |
141 | + | ||
142 | + | // Create the folder if it does not exist. | |
143 | + | if (!Directory.Exists(finalFolder)) | |
144 | + | { | |
145 | + | Directory.CreateDirectory(finalFolder!); | |
146 | + | } | |
147 | + | ||
148 | + | // The problem is: What if the file already exists? | |
149 | + | await _lock.WaitAsync(); | |
150 | + | try | |
151 | + | { | |
152 | + | var expectedFileName = Path.GetFileName(finalFilePath); | |
153 | + | while (File.Exists(finalFilePath)) | |
154 | + | { | |
155 | + | expectedFileName = "_" + expectedFileName; | |
156 | + | finalFilePath = Path.Combine(finalFolder!, expectedFileName); | |
157 | + | } | |
158 | + | ||
159 | + | // Create a new file. | |
160 | + | File.Create(finalFilePath).Close(); | |
161 | + | } | |
162 | + | finally | |
163 | + | { | |
164 | + | _lock.Release(); | |
165 | + | } | |
166 | + | ||
167 | + | await using var fileStream = new FileStream(finalFilePath, FileMode.Create); | |
168 | + | await file.CopyToAsync(fileStream); | |
169 | + | fileStream.Close(); | |
170 | + | ||
171 | + | return Path.GetRelativePath(WorkspaceFolder, finalFilePath); | |
172 | + | } | |
173 | + | ||
174 | + | public string GetFilePhysicalPath(string fileName) | |
175 | + | { | |
176 | + | return Path.Combine(WorkspaceFolder, fileName); | |
177 | + | } | |
178 | + | ||
179 | + | public string AbsolutePathToRelativePath(string absolutePath) | |
180 | + | { | |
181 | + | return Path.GetRelativePath(WorkspaceFolder, absolutePath); | |
182 | + | } | |
183 | + | ||
184 | + | private string RelativePathToUriPath(string relativePath) | |
185 | + | { | |
186 | + | var urlPath = Uri.EscapeDataString(relativePath) | |
187 | + | .Replace("%5C", "/") | |
188 | + | .Replace("%5c", "/") | |
189 | + | .Replace("%2F", "/") | |
190 | + | .Replace("%2f", "/") | |
191 | + | .TrimStart('/'); | |
192 | + | return urlPath; | |
193 | + | } | |
194 | + | ||
195 | + | public string RelativePathToInternetUrl(string relativePath, HttpContext context) | |
196 | + | { | |
197 | + | return $"{context.Request.Scheme}://{context.Request.Host}/download/{RelativePathToUriPath(relativePath)}"; | |
198 | + | } | |
199 | + | } | |
200 | + | ||
201 | + | ``` | |
202 | + | ||
203 | + | 然后,上传分两步,先上传文件,得到一个string,也就是其 RelativePath,再提交表单,把 RelativePath 提交上去。 | |
204 | + | ||
205 | + | 为了体验更好,比如能够展示进度条,我先搓了个 JS 的 Uploader | |
206 | + | ||
207 | + | ```javascript | |
208 | + | class Uploader { | |
209 | + | constructor({ | |
210 | + | fileInput, | |
211 | + | progress, | |
212 | + | progressbar, | |
213 | + | addressInput, | |
214 | + | sizeInMb, | |
215 | + | validExtensions, | |
216 | + | uploadUrl, | |
217 | + | onFile = () => { | |
218 | + | }, | |
219 | + | onUploaded = () => { | |
220 | + | }, | |
221 | + | onReset = () => { | |
222 | + | } | |
223 | + | } = {}) { | |
224 | + | this.fileInput = fileInput; | |
225 | + | this.progress = progress; | |
226 | + | this.progressbar = progressbar; | |
227 | + | this.addressInput = addressInput; | |
228 | + | this.sizeInMb = sizeInMb; | |
229 | + | this.validExtensions = validExtensions; | |
230 | + | this.uploadUrl = uploadUrl; | |
231 | + | this.onFile = onFile; | |
232 | + | this.onUploaded = onUploaded; | |
233 | + | this.onReset = onReset; | |
234 | + | this.onbeforeunloadBackup = window.onbeforeunload; | |
235 | + | } | |
236 | + | ||
237 | + | getExtension(filename) { | |
238 | + | const parts = filename.split('.'); | |
239 | + | return (parts[parts.length - 1]).toLowerCase(); | |
240 | + | } | |
241 | + | ||
242 | + | reset(that) { | |
243 | + | that.addressInput.val(""); | |
244 | + | that.progressbar.css('width', '0%'); | |
245 | + | that.progress.addClass('d-none'); | |
246 | + | window.onbeforeunload = that.onbeforeunloadBackup; | |
247 | + | that.onReset(that); | |
248 | + | } | |
249 | + | ||
250 | + | tryUpload(that) { | |
251 | + | that.onFile(that); | |
252 | + | ||
253 | + | const file = that.fileInput.prop("files")[0]; | |
254 | + | const ext = that.getExtension(file.name); | |
255 | + | if (that.validExtensions.length > 0 && that.validExtensions.indexOf(ext) === -1) { | |
256 | + | return; | |
257 | + | } | |
258 | + | ||
259 | + | if (file.size / 1024 / 1024 > that.sizeInMb) { | |
260 | + | return; | |
261 | + | } | |
262 | + | ||
263 | + | window.onbeforeunload = () => { | |
264 | + | return "Your file is still uploading. Are you sure to quit?"; | |
265 | + | }; | |
266 | + | ||
267 | + | that.progress.removeClass('d-none'); | |
268 | + | that.progressbar.css('width', '0%'); | |
269 | + | that.progressbar.removeClass('bg-success'); | |
270 | + | that.progressbar.addClass('progress-bar-animated'); | |
271 | + | ||
272 | + | const formData = new FormData(); | |
273 | + | formData.append("file", file); | |
274 | + | ||
275 | + | $.ajax({ | |
276 | + | url: that.uploadUrl, | |
277 | + | type: 'post', | |
278 | + | enctype: 'multipart/form-data', | |
279 | + | data: formData, | |
280 | + | cache: false, | |
281 | + | contentType: false, | |
282 | + | processData: false, | |
283 | + | xhr: () => { | |
284 | + | const myXhr = $.ajaxSettings.xhr(); | |
285 | + | if (myXhr.upload) { | |
286 | + | myXhr.upload.addEventListener('progress', (e)=> { | |
287 | + | if (e.lengthComputable) { | |
288 | + | that.progressbar.css('width', 100 * e.loaded / e.total + '%'); | |
289 | + | } | |
290 | + | }, false); | |
291 | + | } | |
292 | + | return myXhr; | |
293 | + | }, | |
294 | + | success: (data)=> { | |
295 | + | window.onbeforeunload = that.onbeforeunloadBackup; | |
296 | + | that.addressInput.val(data.Path); | |
297 | + | that.progressbar.addClass('bg-success'); | |
298 | + | that.progressbar.removeClass('progress-bar-animated'); | |
299 | + | that.progressbar.css('width', '100%'); | |
300 | + | that.onUploaded(data); | |
301 | + | }, | |
302 | + | error: that.reset | |
303 | + | }); | |
304 | + | } | |
305 | + | ||
306 | + | init() { | |
307 | + | const that = this; | |
308 | + | that.fileInput.unbind('change'); | |
309 | + | that.fileInput.on('change', () => { | |
310 | + | that.tryUpload(that); | |
311 | + | }); | |
312 | + | const dropify = that.fileInput.dropify(); | |
313 | + | dropify.on('dropify.afterClear', () => { | |
314 | + | that.reset(that); | |
315 | + | }); | |
316 | + | } | |
317 | + | } | |
318 | + | ||
319 | + | export default Uploader; | |
320 | + | ||
321 | + | ``` | |
322 | + | ||
323 | + | 当然我也搓了一些 CSS: | |
324 | + | ||
325 | + | ```css | |
326 | + | .dropify-wrapper .dropify-message p { | |
327 | + | font-size: 20px !important; | |
328 | + | } | |
329 | + | ||
330 | + | @media (prefers-color-scheme: dark) { | |
331 | + | .dropify-preview { | |
332 | + | background-color: rgb(42, 43, 45) !important; | |
333 | + | } | |
334 | + | ||
335 | + | .dropify-wrapper { | |
336 | + | background-color: rgb(32, 33, 35) !important; | |
337 | + | border: 2px solid #666 !important; | |
338 | + | } | |
339 | + | ||
340 | + | .dropify-wrapper:hover { | |
341 | + | background-image: linear-gradient(-45deg, #161616 25%, transparent 25%, transparent 50%, #161616 50%, #161616 75%, transparent 75%, transparent) !important; | |
342 | + | } | |
343 | + | } | |
344 | + | ``` | |
345 | + | ||
346 | + | 然后我就可以写我的页面了。我可以先弄个 ViewModel: | |
347 | + | ||
348 | + | ```csharp | |
349 | + | using System.ComponentModel.DataAnnotations; | |
350 | + | using Aiursoft.UiStack.Layout; | |
351 | + | ||
352 | + | namespace Aiursoft.Template.Models.ManageViewModels; | |
353 | + | ||
354 | + | public class ChangeAvatarViewModel : UiStackLayoutViewModel | |
355 | + | { | |
356 | + | public ChangeAvatarViewModel() | |
357 | + | { | |
358 | + | PageTitle = "Change Avatar"; | |
359 | + | } | |
360 | + | ||
361 | + | [Display(Name = "Avatar file")] | |
362 | + | [Required(ErrorMessage = "The avatar file is required.")] | |
363 | + | [RegularExpression(@"^avatar.*", ErrorMessage = "The avatar file is invalid. Please upload it again.")] | |
364 | + | public string? AvatarUrl { get; set; } | |
365 | + | } | |
366 | + | ||
367 | + | ``` | |
368 | + | ||
369 | + | 然后我弄个 Controller 负责这个页面: | |
370 | + | ||
371 | + | ```csharp | |
372 | + | // | |
373 | + | // GET: /Manage/ChangeAvatar | |
374 | + | [HttpGet] | |
375 | + | public IActionResult ChangeAvatar() | |
376 | + | { | |
377 | + | return this.StackView(new ChangeAvatarViewModel()); | |
378 | + | } | |
379 | + | ||
380 | + | // | |
381 | + | // POST: /Manage/ChangeAvatar | |
382 | + | [HttpPost] | |
383 | + | [ValidateAntiForgeryToken] | |
384 | + | public async Task<IActionResult> ChangeAvatar(ChangeAvatarViewModel model) | |
385 | + | { | |
386 | + | if (!ModelState.IsValid) | |
387 | + | { | |
388 | + | return this.StackView(model); | |
389 | + | } | |
390 | + | ||
391 | + | // Save the new avatar in database. | |
392 | + | ||
393 | + | return this.StackView(model); | |
394 | + | } | |
395 | + | ||
396 | + | ``` | |
397 | + | ||
398 | + | 真正的页面代码就可以调用我的上传 Component 了: | |
399 | + | ||
400 | + | ```csharp | |
401 | + | @model Aiursoft.Template.Models.ManageViewModels.ChangeAvatarViewModel | |
402 | + | <h2>@Localizer["Change your avatar"]</h2> | |
403 | + | ||
404 | + | <form asp-controller="Manage" asp-action="ChangeAvatar" method="post" class="form-horizontal"> | |
405 | + | <h4>@Localizer["Upload the new avatar file."]</h4> | |
406 | + | <hr /> | |
407 | + | <div asp-validation-summary="All" class="text-danger"></div> | |
408 | + | <div class="form-group"> | |
409 | + | <label asp-for="AvatarUrl" class="col-md-2 control-label"></label> | |
410 | + | <div class="col-md-10"> | |
411 | + | <vc:file-upload asp-for="@Model.AvatarUrl" upload-endpoint="/upload/avatar" allowed-extensions="png bmp jpg" max-size-in-mb="10"></vc:file-upload> | |
412 | + | <span asp-validation-for="AvatarUrl" class="text-danger"></span> | |
413 | + | </div> | |
414 | + | </div> | |
415 | + | <div class="form-group"> | |
416 | + | <div class="col-md-offset-2 col-md-10"> | |
417 | + | <button type="submit" class="btn btn-secondary">@Localizer["Change avatar"]</button> | |
418 | + | </div> | |
419 | + | </div> | |
420 | + | </form> | |
421 | + | ||
422 | + | @* ReSharper disable once Razor.SectionNotResolved *@ | |
423 | + | @section styles{ | |
424 | + | <link rel="stylesheet" href="~/node_modules/dropify/dist/css/dropify.min.css" /> | |
425 | + | <link rel="stylesheet" href="~/styles/uploader.css" /> | |
426 | + | } | |
427 | + | ||
428 | + | @* ReSharper disable once Razor.SectionNotResolved *@ | |
429 | + | @section scripts{ | |
430 | + | <script src="~/node_modules/dropify/dist/js/dropify.min.js"></script> | |
431 | + | } | |
432 | + | ``` | |
433 | + | ||
434 | + | 当然,就像我说的,真正的上传逻辑不应该出现在业务试图里,而是 `<vc:file-upload>` 里。 | |
435 | + | ||
436 | + | 接下来我也抽了componnet: | |
437 | + | ||
438 | + | ```csharp | |
439 | + | using Microsoft.AspNetCore.Mvc; | |
440 | + | using Microsoft.AspNetCore.Mvc.ViewFeatures; | |
441 | + | ||
442 | + | namespace Aiursoft.Template.Views.Shared.Components.FileUpload; | |
443 | + | ||
444 | + | public class FileUpload : ViewComponent | |
445 | + | { | |
446 | + | public IViewComponentResult Invoke( | |
447 | + | ModelExpression aspFor, | |
448 | + | string uploadEndpoint, | |
449 | + | int maxSizeInMb = 2000, | |
450 | + | string? allowedExtensions = null) | |
451 | + | { | |
452 | + | return View(new FileUploadViewModel | |
453 | + | { | |
454 | + | AspFor = aspFor, | |
455 | + | UploadEndpoint = uploadEndpoint, | |
456 | + | MaxSizeInMb = maxSizeInMb, | |
457 | + | AllowedExtensions = allowedExtensions, | |
458 | + | }); | |
459 | + | } | |
460 | + | } | |
461 | + | ||
462 | + | using Microsoft.AspNetCore.Mvc.ViewFeatures; | |
463 | + | ||
464 | + | namespace Aiursoft.Template.Views.Shared.Components.FileUpload; | |
465 | + | ||
466 | + | public class FileUploadViewModel | |
467 | + | { | |
468 | + | public required ModelExpression AspFor { get; init; } | |
469 | + | public required string UploadEndpoint { get; init; } | |
470 | + | public required int MaxSizeInMb { get; init; } | |
471 | + | public required string? AllowedExtensions { get; init; } | |
472 | + | public string UniqueId { get; } = "uploader-" + Guid.NewGuid().ToString("N"); | |
473 | + | } | |
474 | + | ||
475 | + | ``` | |
476 | + | ||
477 | + | 而最终的 component 视图代码如下: | |
478 | + | ||
479 | + | ```razor | |
480 | + | @using Aiursoft.Template.Services | |
481 | + | @model Aiursoft.Template.Views.Shared.Components.FileUpload.FileUploadViewModel | |
482 | + | @inject StorageService Storage | |
483 | + | @inject Microsoft.AspNetCore.Mvc.ViewFeatures.IHtmlGenerator HtmlGenerator | |
484 | + | @{ | |
485 | + | var fileInputId = $"file-input-{Model.UniqueId}"; | |
486 | + | var progressId = $"progress-{Model.UniqueId}"; | |
487 | + | var progressbarId = $"progressbar-{Model.UniqueId}"; | |
488 | + | ||
489 | + | var extensionAttributes = " "; | |
490 | + | if (!string.IsNullOrWhiteSpace(Model.AllowedExtensions)) | |
491 | + | { | |
492 | + | extensionAttributes += $"data-allowed-file-extensions=\"{Model.AllowedExtensions}\" "; | |
493 | + | } | |
494 | + | var defaultValue = Model.AspFor.Model as string; | |
495 | + | if (!string.IsNullOrWhiteSpace(defaultValue)) | |
496 | + | { | |
497 | + | extensionAttributes += $"data-default-file=\"{Storage.RelativePathToInternetUrl(defaultValue, Context)}\" "; | |
498 | + | } | |
499 | + | if (Model.MaxSizeInMb > 0) | |
500 | + | { | |
501 | + | extensionAttributes += $"data-max-file-size=\"{Model.MaxSizeInMb}M\""; | |
502 | + | } | |
503 | + | ||
504 | + | var hiddenInputTag = HtmlGenerator.GenerateTextBox( | |
505 | + | ViewContext, | |
506 | + | Model.AspFor.ModelExplorer, | |
507 | + | Model.AspFor.Name, | |
508 | + | Model.AspFor.Model, | |
509 | + | format: null, | |
510 | + | htmlAttributes: new { style = "width:0;height:0;padding:0;border:none;" }); | |
511 | + | } | |
512 | + | ||
513 | + | <input | |
514 | + | form="fakeForm" | |
515 | + | type="file" | |
516 | + | id="@fileInputId" | |
517 | + | class="dropify" | |
518 | + | data-show-remove="false" | |
519 | + | @Html.Raw(extensionAttributes) /> | |
520 | + | <div id="@progressId" class="progress mb-3 mt-3 d-none"> | |
521 | + | <div id="@progressbarId" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"></div> | |
522 | + | </div> | |
523 | + | @hiddenInputTag | |
524 | + | ||
525 | + | <script type="module"> | |
526 | + | import Uploader from "/scripts/uploader.js"; | |
527 | + | window.addEventListener('load', function () { | |
528 | + | ||
529 | + | const fileInput = $(`#@fileInputId`); | |
530 | + | const progress = $(`#@progressId`); | |
531 | + | const progressbar = $(`#@progressbarId`); | |
532 | + | ||
533 | + | const addressInput = $(`[name="@Model.AspFor.Name"]`); | |
534 | + | ||
535 | + | new Uploader({ | |
536 | + | fileInput: fileInput, | |
537 | + | progress: progress, | |
538 | + | progressbar: progressbar, | |
539 | + | addressInput: addressInput, | |
540 | + | ||
541 | + | sizeInMb: @Model.MaxSizeInMb, | |
542 | + | validExtensions: ('@Model.AllowedExtensions' || '').split(',').filter(Boolean), | |
543 | + | uploadUrl: '@Model.UploadEndpoint' | |
544 | + | }).init(); | |
545 | + | }) | |
546 | + | </script> | |
547 | + | ``` | |
548 | + | ||
549 | + | 这样,我就可以维护好这个 Component 后就不用管了。接下来我只需要在任何需要上传文件的地方调用 `<vc:file-upload>` 就行了。并且其支持原生的 model binding,提交上去的内容仍然可以使用 DataAnnotation 来验证。并且最终交给业务 Controller 的只是一个 string。 | |
550 | + | ||
551 | + | 现在你来分析它的优缺点,并且帮我指出可能存在的可以改进的地方和设计上的缺陷。 |
Новіше
Пізніше