ASP.NET MVC #6
var ext = $('#my_file_field').val().split('.').pop().toLowerCase(); if($.inArray(ext, ['gif','png','jpg','jpeg']) == -1) { alert('invalid extension!'); }
//binds to onchange event of your input field $('#myFile').bind('change', function() { //this.files[0].size gets the size of your file. alert(this.files[0].size); });
کتابخانه سورس باز Stateless، برای طراحی و پیاده سازی «ماشینهای حالت گردش کاری مانند» تهیه شده و مزایای زیر را نسبت به Windows workflow foundation دارا است:
- جمعا 30 کیلوبایت است!
- تمام اجزای آن سورس باز است.
- دارای API روان و سادهای است.
- امکان تبدیل UML state diagrams، به نمونه معادل Stateless بسیار ساده و سریع است.
- به دلیل code first بودن، کار کردن با آن برای برنامه نویسها سادهتر بوده و افزودن یا تغییر اجزای آن با کدنویسی به سادگی میسر است.
دریافت کتابخانه Stateless از Google code و یا از NuGet
پیاده سازی مثال کلید برق با Stateless
در ادامه همان مثال ساده کلید برق قسمت قبل را با Stateless پیاده سازی خواهیم کرد:
using System; using Stateless; namespace StatelessTests { class Program { static void Main(string[] args) { try { string on = "On", off = "Off"; var space = ' '; var onOffSwitch = new StateMachine<string, char>(initialState: off); onOffSwitch.Configure(state: off).Permit(trigger: space, destinationState: on); onOffSwitch.Configure(state: on).Permit(trigger: space, destinationState: off); Console.WriteLine("Press <space> to toggle the switch. Any other key will raise an error."); while (true) { Console.WriteLine("Switch is in state: " + onOffSwitch.State); var pressed = Console.ReadKey(true).KeyChar; onOffSwitch.Fire(trigger: pressed); } } catch (Exception ex) { Console.WriteLine("Exception: " + ex.Message); Console.WriteLine("Press any key to continue..."); Console.ReadKey(true); } } } }
امضای کلاس StateMachine را در ذیل مشاهده میکنید؛ جهت توضیح آرگومانهای جنریک string و char معرفی شده در مثال:
public class StateMachine<TState, TTrigger>
برای مثال در اینجا حالات روشن و خاموش، با رشتههای on و off مشخص شدهاند و رویداد قابل قبول دریافتی، کاراکتر فاصله است.
سپس نیاز است این ماشین حالت را برای معرفی رویدادهایی (trigger در اینجا) که سبب تغییر حالت آن میشوند، تنظیم کنیم. اینکار توسط متدهای Configure و Permit انجام خواهد شد. متد Configure، یکی از حالات از پیش تعیین شده را جهت تنظیم، مشخص میکند و سپس در متد Permit تعیین خواهیم کرد که بر اساس رخدادی مشخص (برای مثال در اینجا فشرده شدن کلید space) وضعیت حالت جاری، به وضعیت جدیدی (destinationState) منتقل شود.
نهایتا این ماشین حالت در یک حلقه بینهایت مشغول به کار خواهد شد. برای نمونه یک Thread پس زمینه (BackgroundWorker) نیز میتواند همین کار را در برنامههای ویندوزی انجام دهد.
یک نکته
علاوه بر روشهای یاد شدهی تشخیص الگوی ماشین حالت که در قسمت قبل بررسی شدند، مورد refactoring انبوهی از if و elseها و یا switchهای بسیار طولانی را نیز میتوان به این لیست افزود.
استفاده از Stateless Designer برای تولید کدهای ماشین حالت
کتابخانه Stateless دارای یک طراح و Code generator بصری سورس باز است که آنرا به شکل افزونهای برای VS.NET میتوانید در سایت Codeplex دریافت کنید. این طراح از کتابخانه GLEE برای رسم گراف استفاده میکند.
کار مقدماتی با آن به نحو زیر است:
الف) فایل StatelessDesignerPackage.vsix را از سایت کدپلکس دریافت و نصب کنید. البته نگارش فعلی آن فقط با VS 2012 سازگار است.
ب) ارجاعی را به اسمبلی stateless به پروژه خود اضافه نمائید (به یک پروژه جدید یا از پیش موجود).
ج) از منوی پروژه، گزینه Add new item را انتخاب کرده و سپس در صفحه ظاهر شده، گزینه جدید Stateless state machine را انتخاب و به پروژه اضافه نمائید.
کار با این طراح، با ادیت XML آن شروع میشود. برای مثال گردش کاری ارسال و تائید یک مطلب جدید را در بلاگی فرضی، به نحو زیر وارد نمائید:
<statemachine xmlns="http://statelessdesigner.codeplex.com/Schema"> <settings> <itemname>BlogPostStateMachine</itemname> <namespace>StatelessTests</namespace> <class>public</class> </settings> <triggers> <trigger>Save</trigger> <trigger>RequireEdit</trigger> <trigger>Accept</trigger> <trigger>Reject</trigger> </triggers> <states> <state start="yes">Begin</state> <state>InProgress</state> <state>Published</state> <state>Rejected</state> </states> <transitions> <transition trigger="Save" from="Begin" to="InProgress" /> <transition trigger="Accept" from="InProgress" to="Published" /> <transition trigger="Reject" from="InProgress" to="Rejected" /> <transition trigger="Save" from="InProgress" to="InProgress" /> <transition trigger="RequireEdit" from="Published" to="InProgress" /> <transition trigger="RequireEdit" from="Rejected" to="InProgress" /> </transitions> </statemachine>
به علاوه کدهای زیر که به صورت خودکار تولید شدهاند:
using Stateless; namespace StatelessTests { public class BlogPostStateMachine { public delegate void UnhandledTriggerDelegate(State state, Trigger trigger); public delegate void EntryExitDelegate(); public delegate bool GuardClauseDelegate(); public enum Trigger { Save, RequireEdit, Accept, Reject, } public enum State { Begin, InProgress, Published, Rejected, } private readonly StateMachine<State, Trigger> stateMachine = null; public EntryExitDelegate OnBeginEntry = null; public EntryExitDelegate OnBeginExit = null; public EntryExitDelegate OnInProgressEntry = null; public EntryExitDelegate OnInProgressExit = null; public EntryExitDelegate OnPublishedEntry = null; public EntryExitDelegate OnPublishedExit = null; public EntryExitDelegate OnRejectedEntry = null; public EntryExitDelegate OnRejectedExit = null; public GuardClauseDelegate GuardClauseFromBeginToInProgressUsingTriggerSave = null; public GuardClauseDelegate GuardClauseFromInProgressToPublishedUsingTriggerAccept = null; public GuardClauseDelegate GuardClauseFromInProgressToRejectedUsingTriggerReject = null; public GuardClauseDelegate GuardClauseFromInProgressToInProgressUsingTriggerSave = null; public GuardClauseDelegate GuardClauseFromPublishedToInProgressUsingTriggerRequireEdit = null; public GuardClauseDelegate GuardClauseFromRejectedToInProgressUsingTriggerRequireEdit = null; public UnhandledTriggerDelegate OnUnhandledTrigger = null; public BlogPost() { stateMachine = new StateMachine<State, Trigger>(State.Begin); stateMachine.Configure(State.Begin) .OnEntry(() => { if (OnBeginEntry != null) OnBeginEntry(); }) .OnExit(() => { if (OnBeginExit != null) OnBeginExit(); }) .PermitIf(Trigger.Save, State.InProgress , () => { if (GuardClauseFromBeginToInProgressUsingTriggerSave != null) return GuardClauseFromBeginToInProgressUsingTriggerSave(); return true; } ) ; stateMachine.Configure(State.InProgress) .OnEntry(() => { if (OnInProgressEntry != null) OnInProgressEntry(); }) .OnExit(() => { if (OnInProgressExit != null) OnInProgressExit(); }) .PermitIf(Trigger.Accept, State.Published , () => { if (GuardClauseFromInProgressToPublishedUsingTriggerAccept != null) return GuardClauseFromInProgressToPublishedUsingTriggerAccept(); return true; } ) .PermitIf(Trigger.Reject, State.Rejected , () => { if (GuardClauseFromInProgressToRejectedUsingTriggerReject != null) return GuardClauseFromInProgressToRejectedUsingTriggerReject(); return true; } ) .PermitReentryIf(Trigger.Save , () => { if (GuardClauseFromInProgressToInProgressUsingTriggerSave != null) return GuardClauseFromInProgressToInProgressUsingTriggerSave(); return true; } ) ; stateMachine.Configure(State.Published) .OnEntry(() => { if (OnPublishedEntry != null) OnPublishedEntry(); }) .OnExit(() => { if (OnPublishedExit != null) OnPublishedExit(); }) .PermitIf(Trigger.RequireEdit, State.InProgress , () => { if (GuardClauseFromPublishedToInProgressUsingTriggerRequireEdit != null) return GuardClauseFromPublishedToInProgressUsingTriggerRequireEdit(); return true; } ) ; stateMachine.Configure(State.Rejected) .OnEntry(() => { if (OnRejectedEntry != null) OnRejectedEntry(); }) .OnExit(() => { if (OnRejectedExit != null) OnRejectedExit(); }) .PermitIf(Trigger.RequireEdit, State.InProgress , () => { if (GuardClauseFromRejectedToInProgressUsingTriggerRequireEdit != null) return GuardClauseFromRejectedToInProgressUsingTriggerRequireEdit(); return true; } ) ; stateMachine.OnUnhandledTrigger((state, trigger) => { if (OnUnhandledTrigger != null) OnUnhandledTrigger(state, trigger); }); } public bool TryFireTrigger(Trigger trigger) { if (!stateMachine.CanFire(trigger)) { return false; } stateMachine.Fire(trigger); return true; } public State GetState { get { return stateMachine.State; } } } }
ماشین حالت فوق دارای چهار حالت شروع، در حال بررسی، منتشر شده و رد شده است. معمول است که این چهار حالت را به شکل یک enum معرفی کنند که در کدهای تولیدی فوق نیز به همین نحو عمل گردیده و public enum State معرف چهار حالت ذکر شده است. همچنین رویدادهای ذخیره، نیاز به ویرایش، ویرایش، تائید و رد نیز توسط public enum Trigger معرفی شدهاند.
در قسمت Transitions، بر اساس یک رویداد (Trigger در اینجا)، انتقال از یک حالت به حالتی دیگر را سبب خواهیم شد.
تعاریف اصلی تنظیمات ماشین حالت، در سازنده کلاس BlogPostStateMachine انجام شده است. این تعاریف نیز بسیار ساده هستند. به ازای هر حالت، یک Configure داریم. در متدهای OnEntry و OnExit هر حالت، یک سری callback function فراخوانی خواهند شد. برای مثال در حالت Rejected یا Approved میتوان ایمیلی را به ارسال کننده مطلب جهت یادآوری وضعیت رخ داده، ارسال نمود.
متدهای PermitIf سبب انتقال شرطی، به حالتی دیگر خواهند شد. برای مثال رد یا تائید یک مطلب نیاز به دسترسی مدیریتی خواهد داشت. این نوع موارد را توسط delgateهای Guard ایی که برای مدیریت شرطها ایجاد کرده است، میتوان تنظیم کرد. PermitReentryIf سبب بازگشت مجدد به همان حالت میگردد. برای مثال ویرایش و ذخیره یک مطلب در حال انتشار، سبب تائید یا رد آن نخواهد شد؛ صرفا عملیات ذخیره صورت گرفته و ماشین حالت مجددا در همان مرحله باقی خواهد ماند.
نحوه استفاده از ماشین حالت تولیدی:
همانطور که عنوان شد، حداقل استفاده از ماشینهای حالت، refactoing انبوهی از if و elseها است که در حالت مدیریت یک چنین گردشهای کاری باید تدارک دید.
namespace StatelessTests { public class BlogPostManager { private BlogPostStateMachine _stateMachine; public BlogPostManager() { configureWorkflow(); } private void configureWorkflow() { _stateMachine = new BlogPostStateMachine(); _stateMachine.GuardClauseFromBeginToInProgressUsingTriggerSave = () => { return UserCanPost; }; _stateMachine.OnBeginExit = () => { /* save data + save state + send an email to admin */ }; _stateMachine.GuardClauseFromInProgressToPublishedUsingTriggerAccept = () => { return UserIsAdmin; }; _stateMachine.GuardClauseFromInProgressToRejectedUsingTriggerReject = () => { return UserIsAdmin; }; _stateMachine.GuardClauseFromInProgressToInProgressUsingTriggerSave = () => { return UserHasEditRights; }; _stateMachine.OnInProgressExit = () => { /* save data + save state + send an email to user */ }; _stateMachine.OnPublishedExit = () => { /* save data + save state + send an email to admin */ }; _stateMachine.GuardClauseFromPublishedToInProgressUsingTriggerRequireEdit = () => { return UserHasEditRights; }; _stateMachine.OnRejectedExit = () => { /* save data + save state + send an email to admin */ }; _stateMachine.GuardClauseFromRejectedToInProgressUsingTriggerRequireEdit = () => { return UserHasEditRights; }; } public bool UserIsAdmin { get { return true; // TODO: Evaluate if user is an admin. } } public bool UserCanPost { get { return true; // TODO: Evaluate if user is authenticated } } public bool UserHasEditRights { get { return true; // TODO: Evaluate if user is owner or admin } } // User actions public void Save() { _stateMachine.TryFireTrigger(BlogPostStateMachine.Trigger.Save); } public void RequireEdit() { _stateMachine.TryFireTrigger(BlogPostStateMachine.Trigger.RequireEdit); } // Admin actions public void Accept() { _stateMachine.TryFireTrigger(BlogPostStateMachine.Trigger.Accept); } public void Reject() { _stateMachine.TryFireTrigger(BlogPostStateMachine.Trigger.Reject); } } }
برای به حرکت درآوردن این ماشین، نیاز به یک سری اکشن متد نیز میباشد. تعدادی از این موارد را در انتهای کلاس فوق ملاحظه میکنید. کد نویسی آنها در حد فراخوانی متد TryFireTrigger ماشین حالت است.
یک نکته:
ماشین حالت تولیدی به صورت پیش فرض در حالت State.Begin قرار دارد. میتوان این مورد را از بانک اطلاعاتی خواند و سپس مقدار دهی نمود تا با هر بار وهله سازی ماشین حالت دقیقا مشخص باشد که در چه مرحلهای قرار داریم و TryFireTrigger بتواند بر این اساس تصمیمگیری کند که آیا مجاز است عملیاتی را انجام دهد یا خیر.
در مقاله قبلی، درباره نحوه نصب و راه اندازی اولین پروژه Xamarin Forms کمی صحبت کردیم. حال وقت آن رسیدهاست که درباره ساختار اپلیکیشنهای Xamarin Forms بیشتر بحث کنیم. در سیستم عاملهای مختلف، رابطهای کاربری با اسامی مختلفی مانند Control ، Widget ، View و Element صدا زده میشوند که هدف تمامی آنها نمایش و ارتباط با کاربر میباشد. در Xamarin Forms به تمام عناصری که در صفحه نمایش نشان داده میشوند، Visual Elements گفته میشود؛ که در سه گروه بندی اصلی قرار میگیرند:
· Page
· Layout
· View
هر چیزی که فضایی را در صفحه اشغال کند، یک Visual Element است. Xamarin Forms از یک ساختار سلسه مراتبی Parent-Child برای UI استفاده میکند. به طور مثال یک اپلیکیشن را در نظر بگیرید. هر اپلیکیشن به طور کلی از چندین صفحه تشکیل شده است. هر Page برای چینش کنترلهای مختلف، از یک سری Layout استفاده میکند و هر Layout هم شامل چندین View مختلف میباشد.
در مقاله قبلی، پروژه اولیه خود را ساختیم. اگر به پروژه Shared مراجعه کنیم، خواهیم دید که این پروژه دارای کلاسی به نام App است. اگر به خاطر داشته باشید، گفتیم که این کلاس اولین Page درون اپلیکیشن را مشخص میکند و همچنین میتوان برای مدیریت LifeCycle اپلیکیشن مانند OnStart و ... از آن استفاده کرد. در متد سازنده، صفحهای به نام MainPage به عنوان اولین صفحه برنامه مشخص شده بود. به کدهای این صفحه بار دیگر نگاهی کنیم تا بتوانیم کمی بر روی این کدها توضیحاتی را ارائه دهیم:
<?xml version="1.0" encoding="utf-8" ?> <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:local="clr-namespace:PreviewerTest" x:Class="PreviewerTest.MainPage"> <Label Text="Welcome to Xamarin Forms!" VerticalOptions="Center" HorizontalOptions="Center" /> </ContentPage>
همانطور که میبینید ساختار سلسه مراتبی را به خوبی میتوانید در این کدها مشاهده کنید. در وهله اول یک ContentPage را به عنوان والد اصلی مشاهده میکنید. Page ها در Xamarin Forms انواع مختلفی دارند که ContentPage یکی از آنهاست و از آن میتوانید به عنوان یک صفحه ساده استفاده کنید.
در درون این صفحه یک Label را به عنوان Child صفحه مشاهده میکنید (تمامی کنترلها در زمارین در زیر گروه View قرار میگیرند).
نتیجه این کدها صفحهای ساده با یک لیبل است که تمامی صفحه را اشغال کردهاست. اگر شما View دیگری را در زیر این لیبل اضافه کنید خواهید دید که این دو، روی هم میافتند و شما نمیتوانید کنترل زیر آن را مشاهده کنید. همانطور که در بالا گفتیم زمارین از المنتی به نام Layout برای چینش عناصر استفاده میکند. Layout های مختلفی در زمارین وجود دارند که هر کدام به طُرق مختلفی این عناصر را در کنار هم میچینند. یکی از آنها StackLayout میباشد.
<?xml version="1.0" encoding="utf-8" ?> <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:local="clr-namespace:XamarinSample" x:Class="XamarinSample.MainPage"> <StackLayout> <Label Text="Welcome to Xamarin Forms!" VerticalOptions="Center" HorizontalOptions="Center" /> <Button Text="Ok!"/> </StackLayout> </ContentPage>
StackLayout عناصر فرزند خود را به صورت افقی و عمودی در کنار هم در صفحه میچیند.
اگر به خاطر داشته باشید، در هنگام ساخت پروژه زمارین چندین پروژه برای پلتفرمهای مختلف در کنار آن ساخته شد. پروژه XamarinSample.Android برای ساخت و مدیریت پروژه در پلتفرم اندروید، مورد استفاده قرار میگیرد. همانطور که گفتیم کدهای درون این پروژهها با پروژه Shared ادغام شده و با هم اجرا خواهند شد. وقت آن رسیده که سری به کدهای آن بزنیم و نحوهی اجرای پروژه Shared را توسط پروژه اندروید ببینیم.
وقتی پروژه اندروید را باز کنید با کلاسی به نام MainActivity مواجه خواهید شد. این کلاس وظیفه ایجاد Activity اصلی برنامه را دارد.
namespace XamarinSample.Droid { [Activity(Label = "XamarinSample", Icon = "@drawable/icon", Theme = "@style/MainTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)] public class MainActivity: global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity { protected override void OnCreate(Bundle bundle) { TabLayoutResource = Resource.Layout.Tabbar; ToolbarResource = Resource.Layout.Toolbar; base.OnCreate(bundle); global::Xamarin.Forms.Forms.Init(this, bundle); LoadApplication(new XamarinSample.App()); } } }
در Attribute بالای سر کلاس، برخی از ویژگیها مانند تم، آیکن، سایز و ... مقداردهی شدهاند. همچنین باعث میشود که در صورت تغییر Orientation و سایز، Activity از اول ساخته نشود. در متد OnCreate علاوه بر استایل دهی به TabLayout و ToolBar ها متدی به نام Forms.Init صدا زده شده است. این متد استاتیک که در تمامی پروژهها فراخوانی میشود، سیستم Xamarin Forms را در هر کدام از پلت فرمها بارگزاری میکند.
طبق یکی از مقالات سری ASP.net MVC سایت با استفاده از Controller فایلهای آپلود شده رو با یک کلید ،خروجی میداد.
بنده همین موضوع رو در تکنولوژی جدید پیاده سازی کردم اما با مشکل عدم نمایش فایل یا تصویر در خروجی مواجه شدم
موجود بودن فیزیکی فایل هم در مسیر wwwroot/StaticImages/ و هم مسیر MyStaticImages/ :
و نحوه آدرس دهی :
<img src='@Url.Action("DownloadFile", "ImageHandler", new {Area = "", id = item.BaseFileGuids, imgSize = ImageHandlerController.ImgSize.M})' alt=""/>
مسیر به درستی نمایش داده شده و فایل هم پس از بررسی توسط : System.IO.File.Exists = true میباشد.
اما در نمایش چه ادرس مستقیم و چه تگ <img> خطای زیر نمایش داده میشود :
هر دو مسیر تست شده با قطعه کد زیر ، اما خطا مشابه میباشد
چه این گزینه hostingEnvironment.WebRootPath_
و چه این گزینه hostingEnvironment.ContentRootPath _
public IActionResult DownloadFile([FromRoute]string id, [FromQuery] ImgSize imgSize) { var result = _baseFileService.GetFileNameAndFileNameOnDsAndFileType(id); if (result == null) return View("Error"); var fileName = result.Item1; string userAgent = Request.Headers["User-Agent"]; if (IsInternetExplorer(userAgent)) { var htencode = HtmlEncoder.Create(); var attachment = string.Format("attachment; filename=\"{0}\"", htencode.Encode(fileName)); _httpContext.HttpContext.Response.Headers.Add("Content-Disposition", attachment); } var rootPath = Path.Combine(_hostingEnvironment.WebRootPath, _settingsAppPathConfig.Value.ServerImagesRootPath); var filepath = Path.Combine(rootPath, imgSize.ToString().ToLower(), result.Item2); if (!System.IO.File.Exists(filepath)) { const string notFoundImage = "notFound.jpg"; var notFoundpath = Path.Combine(rootPath , notFoundImage); string contentType; new FileExtensionContentTypeProvider().TryGetContentType(notFoundImage, out contentType); return File(notFoundpath, contentType, notFoundImage); } string contentTypebase; new FileExtensionContentTypeProvider().TryGetContentType(result.Item3, out contentTypebase); return File(filepath, contentTypebase, fileName); }
بررسی یک مثال: تهیه یک برنامهی Blazor 8x برای نمایش لیست محصولات، به همراه جزئیات آنها
به لطف وجود SSR در Blazor 8x، میتوان HTML نهایی کامپوننتها و صفحات Blazor را همانند صفحات MVC و یا Razor pages، در سمت سرور تهیه و بازگشت داد. این خروجی در نهایت یک static HTML بیشتر نیست و گاهی از اوقات ما به بیش از یک خروجی ساده HTML ای نیاز داریم.
در این مثال که بر اساس قالب dotnet new blazor --interactivity Server تهیه میشود، قصد داریم موارد زیر را پیاده سازی کنیم:
- صفحهای که یک لیست محصولات فرضی را نمایش میدهد : بر اساس SSR
- صفحهای که جزئیات یک محصول را نمایش میدهد: بر اساس SSR
- دکمهای در ذیل قسمت نمایش جزئیات یک محصول، برای دریافت و نمایش لیست محصولات مشابه و مرتبط: بر اساس Blazor server islands
یعنی تا جائیکه ممکن است قصد نداریم تمام صفحات و تمام قسمتهای برنامه را با فعالسازی سراسری حالت تعاملی Blazor server که در قسمتهای قبل در مورد آن توضیح داده شد، پیاده سازی کنیم. میخواهیم فقط قسمت کوچکی از این سناریو را که واقعا نیاز به یک چنین قابلیتی را دارد، توسط یک جزیرهی تعاملی Blazor server واقع شدهی در قسمتی از یک صفحهی استاتیک SSR، مدیریت کنیم.
مدل برنامه: رکوردی برای ذخیره سازی اطلاعات یک محصول
namespace BlazorDemoApp.Models; public record Product { public int Id { get; set; } public required string Title { get; set; } public required string Description { get; set; } public decimal Price { get; set; } public List<int> Related { get; set; } = new(); }
سرویس برنامه: سرویسی برای بازگشت لیست محصولات
چون Blazor Server و SSR هر دو بر روی سرور اجرا میشوند، از لحاظ دسترسی به اطلاعات و کار با سرویسها، هماهنگی کاملی وجود داشته و میتوان کدهای یکسان و یکدستی را در اینجا بکار گرفت.
در ادامه کدهای کامل سرویس Services\ProductStore.cs را مشاهده میکنید:
using BlazorDemoApp.Models; namespace BlazorDemoApp.Services; public interface IProductStore { IList<Product> GetAllProducts(); Product GetProduct(int id); IList<Product> GetRelatedProducts(int productId); } public class ProductStore : IProductStore { private static readonly List<Product> ProductsDataSource = new() { new Product { Id = 1, Title = "Smart speaker", Price = 22m, Description = "This smart speaker delivers excellent sound quality and comes with built-in voice control, offering an impressive music listening experience.", Related = new List<int> { 2, 3 }, }, new Product { Id = 2, Title = "Regular speaker", Price = 89m, Description = "Enjoy room-filling sound with this regular speaker. With its slick design, it perfectly fits into any room in your house.", Related = new List<int> { 1, 3 }, }, new Product { Id = 3, Title = "Speaker cable", Price = 12m, Description = "This high-quality speaker cable ensures a reliable and clear audio connection for your sound system.", }, }; public IList<Product> GetAllProducts() => ProductsDataSource; public Product GetProduct(int id) => ProductsDataSource.Single(p => p.Id == id); public IList<Product> GetRelatedProducts(int productId) { var product = ProductsDataSource.Single(x => x.Id == productId); return ProductsDataSource.Where(p => product.Related.Contains(p.Id)).ToList(); } }
این سرویس را باید در فایل Program.cs برنامه به صورت زیر معرفی کرد تا در فایلهای razor برنامهی جاری قابل دسترسی شود:
builder.Services.AddScoped<IProductStore, ProductStore>();
تکمیل صفحهی نمایش لیست محصولات
قصد داریم زمانیکه کاربر برای مثال به آدرس فرضی http://localhost:5136/products مراجعه کرد، با تصویر لیستی از محصولات مواجه شود:
کدهای این صفحه را که در فایل Components\Pages\Store\ProductsList.razor قرار میگیرند، در ادامه مشاهده میکنید:
@page "/Products" @using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store @attribute [StreamRendering] <h3>Products</h3> @if (_products == null) { <p>Loading...</p> } else { @foreach (var item in _products) { <a href="/ProductDetails/@item.Id"> <div> <div> <h5>@item.Title</h5> </div> <div> <h5>@item.Price.ToString("c")</h5> </div> </div> </a> } } @code { private IList<Product>? _products; protected override Task OnInitializedAsync() => GetProductsAsync(); private async Task GetProductsAsync() { await Task.Delay(1000); // Simulates asynchronous loading to demonstrate streaming rendering _products = Store.GetAllProducts(); } }
- جهت دسترسی به سرویس لیست محصولات، ابتدا سرویس IProductStore به این صفحه تزریق شدهاست.
- سپس در روال رویدادگردان آغازین OnInitializedAsync، کار دریافت اطلاعات و انتساب آن به لیستی، صورت گرفتهاست.
- در این متد جهت شبیه سازی یک عملیات async از یک Task.Delay استفاده شدهاست.
- چون این صفحه، یک صفحهی SSR عادی است، بدون تعریف ویژگی StreamRendering در آن، پس از اجرای برنامه، هیچگاه قسمت loading که در حالت products == null_ قرار است ظاهر شود، نمایش داده نمیشود؛ چون در این حالت (حذف نوع رندر)، صفحهی نهایی که به کاربر ارائه خواهد شد، یک صفحهی استاتیک کاملا رندر شدهی در سمت سرور است و کاربر باید تا زمان پایان این رندر در سمت سرور، منتظر بماند و سپس صفحهی نهایی را دریافت و مشاهده کند. در حالت Streaming rendering، ابتدا میتوان یک قالب HTML ای را بازگشت داد و سپس مابقی محتوای آنرا به محض آماده شدن در طی چند مرحله بازگشت داد.
- لینکهای نمایش داده شدهی در اینجا، به صفحهی ProductDetails اشاره میکنند که در آن، جزئیات محصول انتخابی نمایش داده میشوند.
تکمیل صفحهی نمایش جزئیات یک محصول
در صفحهی کامپوننت Components\Pages\Store\ProductDetails.razor، کار نمایش جزئیات محصول انتخابی صورت میگیرد:
@page "/ProductDetails/{ProductId}" @using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store @attribute [StreamRendering] @if (_product == null) { <p>Loading...</p> } else { <div> <div> <h5> @_product.Title (@_product.Price.ToString("C")) </h5> <p> @_product.Description </p> </div> @if (_product.Related.Count > 0) { <div> <RelatedProducts ProductId="Convert.ToInt32(ProductId)" /> </div> } </div> <NavLink href="/Products">Back</NavLink> } @code { private Product? _product; [Parameter] public string? ProductId { get; set; } protected override Task OnInitializedAsync() => GetProductAsync(); private async Task GetProductAsync() { await Task.Delay(1000); // Simulates asynchronous loading to demonstrate streaming rendering _product = Store.GetProduct(Convert.ToInt32(ProductId)); } }
- باتوجه به نحوهی تعریف مسیریابی این صفحه، پارامتر ProductId از طریق آدرسی مانند http://localhost:5136/ProductDetails/1 دریافت میشود.
- سپس این ProductId را در روال رخدادگردان OnInitializedAsync، برای یافتن جزئیات محصول انتخابی از سرویس تزریقی IProductStore، بکار میگیریم.
- در اینجا نیز از Task.Delay برای شبیه سازی یک عملیات طولانی async مانند دریافت اطلاعات از یک بانک اطلاعاتی، کمک گرفته شدهاست.
- همچنین برای نمایش قسمت loading صفحه در حالت SSR، بازهم از StreamRendering استفاده کردهایم.
- اگر دقت کرده باشید، ذیل تصویر اطلاعات محصول، دکمهای نیز جهت بارگذاری اطلاعات محصولات مشابه، قرار دارد که ProductId محصول انتخابی را دریافت میکند:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" />
تکمیل کامپوننت نمایش لیست محصولات مشابه و مرتبط
در فایل Components\Pages\Store\RelatedProducts.razor، کار نمایش یک دکمه و سپس نمایش لیستی از محصولات مشابه، صورت میگیرد:
@using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store <button @onclick="LoadRelatedProducts">Related products</button> @if (_loadRelatedProducts) { @if (_relatedProducts == null) { <p>Loading...</p> } else { <div> @foreach (var item in _relatedProducts) { <a href="/ProductDetails/@item.Id"> <div> <h5>@item.Title (@item.Price.ToString("C"))</h5> </div> </a> } </div> } } @code{ private IList<Product>? _relatedProducts; private bool _loadRelatedProducts; [Parameter] public int ProductId { get; set; } private async Task LoadRelatedProducts() { _loadRelatedProducts = true; await Task.Delay(1000); // Simulates asynchronous loading to demonstrate InteractiveServer mode _relatedProducts = Store.GetRelatedProducts(ProductId); } }
تعاملی کردن کامپوننت نمایش لیست محصولات مشابه
مشکل! اگر در این حالت برنامه را اجرا کرده و بر روی دکمهی related products کلیک کنیم، هیچ اتفاقی رخ نمیدهد! یعنی روال رویدادگران LoadRelatedProducts اصلا اجرا نمیشود. علت اینجا است که صفحات SSR، در نهایت یک static HTML بیشتر نیستند و فاقد قابلیتهای تعاملی، مانند واکنش نشان دادن به کلیک بر روی یک دکمه هستند.
محدودیتی که به همراه صفحات SSR وجود دارد این است: این نوع کامپوننتها و صفحات فقط یکبار رندر میشوند و نه بیشتر. بله میتوان بر روی آنها دهها دکمه، نوارهای لغزان، دراپداون و غیره را قرار داد، اما ... نمیتوان هیچگونه تعاملی را با آنها داشت. کامپوننت نهایی رندر شده و نمایش داده شده، دیگر در هیچجائی اجرا نمیشود. در این حالت است که میتوان تصمیم گرفت که نیاز است قسمتی از این صفحه، تعاملی شود.
به همین جهت باید نحوهی رندر کامپوننت RelatedProducts را به صورت یک جزیرهی تعاملی Blazor server درآورد تا رویداد منتسب به دکمهی related products موجود در آن، پردازش شود. بنابراین به صفحهی ProductDetails.razor مراجعه کرده و rendermode@ این کامپوننت را به صورت زیر به حالت InteractiveServer تغییر میدهیم:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" @rendermode="@InteractiveServer"/>
نحوهی پردازش پشت صحنهی این نوع صفحات هم جالب است. برای اینکار به برگهی network مخصوص developer tools مرورگر مراجعه کرده و مراحل رسیدن به صفحهی نمایش جزئیات محصول را طی میکنیم:
- اگر دقت کنید، جابجایی بین صفحات، با استفاده از fetch انجام شده؛ یعنی با اینکه این صفحات در اصل static HTML خالص هستند، اما ... کار full reload صفحه مانند ASP.NET Web forms قدیمی انجام نمیشود (و یا حتی برنامههای MVC و Razor pages) و نمایش صفحات، Ajax ای است و با fetch استاندارد آن صورت میگیرد تا هنوز هم حس و حال SPA بودن برنامه حفظ شود. همچنین اطلاعات DOM کل صفحه را هم بهروز رسانی نمیکند؛ فقط موارد تغییر یافته در اینجا به روز رسانی خواهند شد.
این موارد توسط فایل blazor.web.js درج شدهی در کامپوننت آغازین App.razor، به صورت خودکار مدیریت میشوند:
<script src="_framework/blazor.web.js"></script>
به علاوه در این حالت ایجکسی fetch، کار دریافت مجدد فایلهای استاتیک مرتبط یک صفحه، مانند فایلهای js.، css.، تصاویر و غیره، مجددا انجام نمیشود که این مورد خود مزیتی است نسبت به حالت متداول برنامههای ASP.NET Core MVC و یا Razor pages. در حالت Blazor 8x SSR، فقط یک partial update از نوع Ajax ای انجام میشود.
به این قابلیت، enhanced navigation هم گفته میشود. برای مثال زمانیکه یک فرم SSR را در Blazor 8x به سمت سرور ارسال میکنیم، موقعیت scroll به صورت خودکار ذخیره و بازیابی میشود تا کاربر با یک full post back مواجه نشده و موقعیت جاری خود را در صفحه از دست ندهد (چنین ایدهای، یک زمانی در برنامههای ASP.NET Web forms هم برقرار بود و هست! به نظر مایکروسافت هنوز دلتنگ طراحی قدیمی ASP.NET Web forms است!).
- همچنین به محض نمایش صفحهی جزئیات محصول، پس از پایان کار نمایش آن، یک اتصال وبسوکت هم برقرار شده که مرتبط با جزیرهی تعاملی Blazor server تعریف شده، یا همان کامپوننت RelatedProducts است.
- یک disconnect را هم در اینجا مشاهده میکنید. اگر به یک صفحهی تعاملی مراجعه کنیم، همانطور که مشخص است، یک اتصال SignalR برقرار میشود (که به آن در اینجا circuit هم میگویند). اما اگر از این صفحه به سمت یک صفحهی SSR حرکت کنیم، پس از نمایش آن صفحه، اتصال SignalR قبلی که دیگر نیازی به آن نیست، بسته خواهد شد تا منابع سمت سرور، رها شوند.
در حین disconnect، شماره ID اتصال SignalR ای که دیگر به آن نیازی نیست، به برنامه ارسال میشود تا به صورت خودکار در سمت سرور بسته شود. تمام این موارد توسط blazor.web.js فریمورک، مدیریت میشوند.
در این تصویر ابتدا به آدرس http://localhost:5136/ProductDetails/1 مراجعه کردهایم که سبب برقراری اتصال یک وبسوکت شدهاست. سپس با کلیک بر روی دکمهی back، به صفحهی SSR مشاهدهی لیست محصولات برگشتهایم. در این حالت، دستور قطع اتصال SignalR قبلی صادر شدهاست.
نحوهی مدیریت Pre-rendering در جزایر تعاملی Blazor 8x
به صورت پیشفرض زمانیکه از حالت رندر InteractiveServer استفاده میکنیم، قابلیت pre-rendering آن نیز فعال است. یعنی ابتدا حداقل قالب و قسمتهای ثابت کامپوننت، در سمت سرور پردازش و رندر شده و سپس به سمت کلاینت ارسال میشوند. در این حالت کاربر، تجربهی کاربری روانتری را شاهد خواهد بود؛ چون برای مدتی نباید منتظر آماده شدن کل UI مرتبط باشد و حداقل، قسمتهایی از صفحه که تعاملی نیستند، قابل دسترسی و مشاهده هستند.
اگر به هر دلیلی نیاز به غیرفعال کردن این قابلیت را دارید، باید به صورت زیر عمل کرد:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" @rendermode="@(new InteractiveServerRenderMode(false))"/>
نحوهی تعریف خواص استاتیک InteractiveServer بکار گرفته شده و یا کلاس InteractiveServerRenderMode را در ادامه مشاهده میکنید. جهت سهولت تعریف این موارد، سطر زیر که یک using static است، به فایل Imports.razor_ اضافه شدهاست:
@using static Microsoft.AspNetCore.Components.Web.RenderMode
public static class RenderMode { public static InteractiveServerRenderMode InteractiveServer { get; } = new InteractiveServerRenderMode(); public static InteractiveWebAssemblyRenderMode InteractiveWebAssembly { get; } = new InteractiveWebAssemblyRenderMode(); public static InteractiveAutoRenderMode InteractiveAuto { get; } = new InteractiveAutoRenderMode(); } public class InteractiveServerRenderMode : IComponentRenderMode { public InteractiveServerRenderMode() : this(true) { } public InteractiveServerRenderMode(bool prerender) => this.Prerender = prerender; public bool Prerender { get; } }
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید: Blazor8x-Server-Normal.zip
معرفی Kendo UI
مشکل از همون دوبار تعریف جی کوئری بود.
با inspect element که بالا اومدم فایلهای جاوااسکرپیتی رو بررسی کردم یک فایل با نام js بود که محتواش کدهای جی کوئری بود و توسط قالب بوت استراپ در آخر صفحه مستر صدا زده میشد و برای همین بود موقعی که فایلهای جی کوئری رو از صفحه حذف کردم خطای ناشناخته $ اومد ، چون هنوز بار نشده بوده.
نمیدونم چرا خود قالب دو مرتبه این فایل رو صدا زده بود.
بابت گرفتن وقت دوستان عذر میخوام
الف) Policies
ب) Role Claims
سیاستهای دسترسی یا Policies در ASP.NET Core Identity
ASP.NET Core Identity هنوز هم از مفهوم Roles پشتیبانی میکند. برای مثال میتوان مشخص کرد که اکشن متدی و یا تمام اکشن متدهای یک کنترلر تنها توسط کاربران دارای نقش Admin قابل دسترسی باشند. اما نقشها نیز در این سیستم جدید تنها نوعی از سیاستهای دسترسی هستند.
[Authorize(Roles = ConstantRoles.Admin)] public class RolesManagerController : Controller
اما نقشهای ثابت، بسیار محدود و غیر قابل انعطاف هستند. برای رفع این مشکل مفهوم جدیدی را به نام Policy اضافه کردهاند.
[Authorize(Policy="RequireAdministratorRole")] public IActionResult Get() { /* .. */ }
برای مثال اگر بخواهیم تک نقش Admin را به صورت یک سیاست دسترسی جدید تعریف کنیم، روش کار به صورت ذیل خواهد بود:
public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddAuthorization(options => { options.AddPolicy("RequireAdministratorRole", policy => policy.RequireRole("Admin")); }); }
و یا بجای اینکه چند نقش مجاز به دسترسی منبعی را با کاما از هم جدا کنیم:
[Authorize(Roles = "Administrator, PowerUser, BackupAdministrator")]
options.AddPolicy("ElevatedRights", policy => policy.RequireRole("Administrator", "PowerUser", "BackupAdministrator"));
[Authorize(Policy = "ElevatedRights")] public IActionResult Shutdown() { return View(); }
سیاستهای دسترسی تنها به نقشها محدود نیستند:
services.AddAuthorization(options => { options.AddPolicy("EmployeeOnly", policy => policy.RequireClaim("EmployeeNumber")); });
[Authorize(Policy = "EmployeeOnly")] public IActionResult VacationBalance() { return View(); }
سیاستهای دسترسی پویا در ASP.NET Core Identity
مهمترین مزیت کار با سیاستهای دسترسی، امکان سفارشی سازی و تهیهی نمونههای پویای آنها هستند؛ موردی که با نقشهای ثابت سیستم قابل پیاده سازی نبوده و در نگارشهای قبلی، جهت پویا سازی آن، یکی از روشهای بسیار متداول، تهیهی فیلتر Authorize سفارشی سازی شده بود. اما در اینجا دیگر نیازی نیست تا فیلتر Authorize را سفارشی سازی کنیم. با پیاده سازی یک AuthorizationHandler جدید و معرفی آن به سیستم، پردازش سیاستهای دسترسی پویای به منابع، فعال میشود.
پیاده سازی سیاستهای پویای دسترسی شامل مراحل ذیل است:
1- تعریف یک نیازمندی دسترسی جدید
public class DynamicPermissionRequirement : IAuthorizationRequirement { }
2- پیاده سازی یک AuthorizationHandler استفاده کنندهی از نیازمندی دسترسی تعریف شده
پس از اینکه نیازمندی DynamicPermissionRequirement را تعریف کردیم، در ادامه باید یک AuthorizationHandler استفاده کنندهی از آن را تعریف کنیم:
public class DynamicPermissionsAuthorizationHandler : AuthorizationHandler<DynamicPermissionRequirement> { private readonly ISecurityTrimmingService _securityTrimmingService; public DynamicPermissionsAuthorizationHandler(ISecurityTrimmingService securityTrimmingService) { _securityTrimmingService = securityTrimmingService; _securityTrimmingService.CheckArgumentIsNull(nameof(_securityTrimmingService)); } protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, DynamicPermissionRequirement requirement) { var mvcContext = context.Resource as AuthorizationFilterContext; if (mvcContext == null) { return Task.CompletedTask; } var actionDescriptor = mvcContext.ActionDescriptor; var area = actionDescriptor.RouteValues["area"]; var controller = actionDescriptor.RouteValues["controller"]; var action = actionDescriptor.RouteValues["action"]; if(_securityTrimmingService.CanCurrentUserAccess(area, controller, action)) { context.Succeed(requirement); } else { context.Fail(); } return Task.CompletedTask; } }
در کلاس تهیه شده باید متد HandleRequirementAsync آنرا بازنویسی کرد و اگر در این بین، منطق سفارشی ما context.Succeed را فراخوانی کند، به معنای برآورده شدن سیاست دسترسی بوده و کاربر جاری میتواند به منبع درخواستی، بلافاصله دسترسی یابد و اگر context.Fail فراخوانی شود، در همینجا دسترسی کاربر قطع شده و HTTP status code مساوی 401 (عدم دسترسی) را دریافت میکند.
منطق سفارشی پیاده سازی شده نیز به این صورت است:
نام ناحیه، کنترلر و اکشن متد درخواستی کاربر از مسیریابی جاری استخراج میشوند. سپس توسط سرویس سفارشی ISecurityTrimmingService تهیه شده، بررسی میکنیم که آیا کاربر جاری به این سه مؤلفه دسترسی دارد یا خیر؟
3- معرفی سیاست دسترسی پویای تهیه شده به سیستم
معرفی سیاست کاری پویا و سفارشی تهیه شده، شامل دو مرحلهی زیر است:
private static void addDynamicPermissionsPolicy(this IServiceCollection services) { services.AddScoped<IAuthorizationHandler, DynamicPermissionsAuthorizationHandler>(); services.AddAuthorization(opts => { opts.AddPolicy( name: ConstantPolicies.DynamicPermission, configurePolicy: policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new DynamicPermissionRequirement()); }); }); }
سپس یک Policy جدید را با نام دلخواه DynamicPermission تعریف کرده و نیازمندی علامتگذار خود را به عنوان یک policy.Requirements جدید، اضافه میکنیم. همانطور که ملاحظه میکنید یک وهلهی جدید از DynamicPermissionRequirement در اینجا ثبت شدهاست. همین وهله به متد HandleRequirementAsync نیز ارسال میشود. بنابراین اگر نیاز به ارسال پارامترهای بیشتری به این متد وجود داشت، میتوان خواص مرتبطی را به کلاس DynamicPermissionRequirement نیز اضافه کرد.
همانطور که مشخص است، در اینجا یک نیازمندی را میتوان ثبت کرد و نه Handler آنرا. این Handler از سیستم تزریق وابستگیها، بر اساس آرگومان جنریک AuthorizationHandler پیاده سازی شده، به صورت خودکار یافت شده و اجرا میشود (بنابراین اگر Handler شما اجرا نشد، مطمئن شوید که حتما آنرا به سیستم تزریق وابستگیها معرفی کردهاید).
پس از آن هر کنترلر یا اکشن متدی که از این سیاست دسترسی پویای تهیه شده استفاده کند:
[Authorize(Policy = ConstantPolicies.DynamicPermission)] [DisplayName("کنترلر نمونه با سطح دسترسی پویا")] public class DynamicPermissionsSampleController : Controller
سرویس ISecurityTrimmingService چگونه کار میکند؟
کدهای کامل ISecurityTrimmingService را در کلاس SecurityTrimmingService میتوانید مشاهده کنید.
پیشنیاز درک عملکرد آن، آشنایی با دو قابلیت زیر هستند:
الف) «روش یافتن لیست تمام کنترلرها و اکشن متدهای یک برنامهی ASP.NET Core»
دقیقا از همین سرویس توسعه داده شدهی در مطلب فوق، در اینجا نیز استفاده شدهاست؛ با یک تفاوت تکمیلی:
public interface IMvcActionsDiscoveryService { ICollection<MvcControllerViewModel> MvcControllers { get; } ICollection<MvcControllerViewModel> GetAllSecuredControllerActionsWithPolicy(string policyName); }
بنابراین همینقدر که تعریف ذیل یافت شود، این اکشن متد نیز در صفحهی مدیریت سطوح دسترسی پویا لیست خواهد شد.
[Authorize(Policy = ConstantPolicies.DynamicPermission)]
ابتدا به مدیریت نقشهای ثابت سیستم میرسیم. سپس به هر نقش میتوان یک Claim جدید را با مقدار area:controller:action انتساب داد.
به این ترتیب میتوان به یک نقش، تعدادی اکشن متد را نسبت داد و سطوح دسترسی به آنها را پویا کرد. اما ذخیره سازی آنها چگونه است و چگونه میتوان به اطلاعات نهایی ذخیره شده دسترسی پیدا کرد؟
مفهوم جدید Role Claims در ASP.NET Core Identity
تا اینجا موفق شدیم تمام اکشن متدهای دارای سیاست دسترسی سفارشی سازی شدهی خود را لیست کنیم، تا بتوان آنها را به صورت دلخواهی انتخاب کرد و سطوح دسترسی به آنها را به صورت پویا تغییر داد. اما این اکشن متدهای انتخاب شده را در کجا و به چه صورتی ذخیره کنیم؟
برای ذخیره سازی این اطلاعات نیازی نیست تا جدول جدیدی را به سیستم اضافه کنیم. جدول جدید AppRoleClaims به همین منظور تدارک دیده شدهاست.
وقتی کاربری عضو یک نقش است، به صورت خودکار Role Claims آن نقش را نیز به ارث میبرد. هدف از نقشها، گروه بندی کاربران است. توسط Role Claims میتوان مشخص کرد این نقشها چه کارهایی را میتوانند انجام دهند. اگر از قسمت قبل بخاطر داشته باشید، سرویس توکار UserClaimsPrincipalFactory دارای مرحلهی 5 ذیل است:
«5) اگر یک نقش منتسب به کاربر دارای Role Claim باشد، این موارد نیز واکشی شده و به کوکی او به عنوان یک Claim جدید اضافه میشوند. در ASP.NET Identity Core نقشها نیز میتوانند Claim داشته باشند (امکان پیاده سازی سطوح دسترسی پویا).»
به این معنا که با لاگین شخص به سیستم، تمام اطلاعات مرتبط به او که در جدول AppRoleClaims وجود دارند، به کوکی او به صورت خودکار اضافه خواهند شد و دسترسی به آنها فوق العاده سریع است.
در کنترلر DynamicRoleClaimsManagerController، یک Role Claim Type جدید به نام DynamicPermissionClaimType اضافه شدهاست و سپس ID اکشن متدهای انتخابی را به نقش جاری، تحت Claim Type عنوان شده، اضافه میکند (تصویر فوق). این ID به صورت area:controller:action طراحی شدهاست. به همین جهت است که در DynamicPermissionsAuthorizationHandler همین سه جزء از سیستم مسیریابی استخراج و در سرویس SecurityTrimmingService مورد بررسی قرار میگیرد:
return user.HasClaim(claim => claim.Type == ConstantPolicies.DynamicPermissionClaimType && claim.Value == currentClaimValue);
متد HasClaim هیچگونه رفت و برگشتی را به بانک اطلاعاتی ندارد و اطلاعات خود را از کوکی شخص دریافت میکند. متد user.IsInRole نیز به همین نحو عمل میکند.
Tag Helper جدید SecurityTrimming
اکنون که سرویس ISecurityTrimmingService را پیاده سازی کردهایم، از آن میتوان جهت توسعهی SecurityTrimmingTagHelper نیز استفاده کرد:
public override void Process(TagHelperContext context, TagHelperOutput output) { context.CheckArgumentIsNull(nameof(context)); output.CheckArgumentIsNull(nameof(output)); // don't render the <security-trimming> tag. output.TagName = null; if(_securityTrimmingService.CanCurrentUserAccess(Area, Controller, Action)) { // fine, do nothing. return; } // else, suppress the output and generate nothing. output.SuppressOutput(); }
نمونهای از کاربرد آنرا در ReportsMenu.cshtml_ میتوانید مشاهده کنید:
<security-trimming asp-area="" asp-controller="DynamicPermissionsTest" asp-action="Products"> <li> <a asp-controller="DynamicPermissionsTest" asp-action="Products" asp-area=""> <span class="left5 fa fa-user" aria-hidden="true"></span> گزارش از لیست محصولات </a> </li> </security-trimming>
برای آزمایش آن یک کاربر جدید را به سیستم DNT Identity اضافه کنید. سپس آنرا در گروه نقشی مشخص قرار دهید (منوی مدیریتی،گزینهی مدیریت نقشهای سیستم). سپس به این گروه دسترسی به تعدادی از آیتمهای پویا را بدهید (گزینهی مشاهده و تغییر لیست دسترسیهای پویا). سپس با این اکانت جدید به سیستم وارد شده و بررسی کنید که چه تعدادی از آیتمهای منوی «گزارشات نمونه» را میتوانید مشاهده کنید (تامین شدهی توسط ReportsMenu.cshtml_).
مدیریت اندازهی حجم کوکیهای ASP.NET Core Identity
همانطور که ملاحظه کردید، جهت بالابردن سرعت دسترسی به اطلاعات User Claims و Role Claims، تمام اطلاعات مرتبط با آنها، به کوکی کاربر وارد شدهی به سیستم، اضافه میشوند. همین مساله در یک سیستم بزرگ با تعداد صفحات بالا، سبب خواهد شد تا حجم کوکی کاربر از 5 کیلوبایت بیشتر شده و توسط مرورگرها مورد قبول واقع نشوند و عملا سیستم از کار خواهد افتاد.
برای مدیریت یک چنین مسالهای، امکان ذخیره سازی کوکیهای شخص در داخل بانک اطلاعاتی نیز پیش بینی شدهاست. زیر ساخت آنرا در مطلب «تنظیمات کش توزیع شدهی مبتنی بر SQL Server در ASP.NET Core» پیشتر در این سایت مطالعه کردید و در پروژهی DNT Identity بکارگرفته شدهاست.
اگر به کلاس IdentityServicesRegistry مراجعه کنید، یک چنین تنظیمی در آن قابل مشاهده است:
var ticketStore = provider.GetService<ITicketStore>(); identityOptionsCookies.ApplicationCookie.SessionStore = ticketStore; // To manage large identity cookies
الف) DistributedCacheTicketStore
ب) MemoryCacheTicketStore
اولی از همان زیرساخت «تنظیمات کش توزیع شدهی مبتنی بر SQL Server در ASP.NET Core» استفاده میکند و دومی از IMemoryCache توکار ASP.NET Core برای پیاده سازی مکان ذخیره سازی محتوای کوکیهای سیستم، بهره خواهد برد.
باید دقت داشت که اگر حالت دوم را انتخاب کنید، با شروع مجدد برنامه، تمام اطلاعات کوکیهای کاربران نیز حذف خواهند شد. بنابراین استفادهی از حالت ذخیره سازی آنها در بانک اطلاعاتی منطقیتر است.
نحوهی تنظیم سرویس ITicketStore را نیز در متد setTicketStore میتوانید مشاهده کنید و در آن، در صورت انتخاب حالت بانک اطلاعاتی، ابتدا تنظیمات کش توزیع شده، صورت گرفته و سپس کلاس DistributedCacheTicketStore به عنوان تامین کنندهی ITicketStore به سیستم تزریق وابستگیها معرفی میشود.
همین اندازه برای انتقال محتوای کوکیهای کاربران به سرور کافی است و از این پس تنها اطلاعاتی که به سمت کلاینت ارسال میشود، ID رمزنگاری شدهی این کوکی است، جهت بازیابی آن از بانک اطلاعاتی و استفادهی خودکار از آن در برنامه.
کدهای کامل این سری را در مخزن کد DNT Identity میتوانید ملاحظه کنید.
مراحل نحوه اجرای برنامه:
نصب کتابخانههای زیر:
//client Install-Package angularjs Install-Package angular-strap Install-Package Microsoft.AspNet.SignalR.JS install-package AngularJs.SignalR.Hub Install-Package jQuery.TimeAgo Install-Package FontAwesome Install-Package toastr Install-Package Twitter.Bootstrap.RTL bower install angular-smilies //server Install-Package Newtonsoft.Json Install-Package Microsoft.AspNet.SignalR Install-Package EntityFramework
گامهای برنامه:
public partial class Message { public int Id { get; set; } public string Sender { get; set; } public string Receiver { get; set; } public string Body { get; set; } public DateTimeOffset? CreationTime { get; set; } public int? SessionId { get; set; } public virtual Session Session { get; set; } } public partial class Session { public Session() { Messages = new List<Message>(); Sessions = new List<Session>(); } public int Id { get; set; } public string AgentName { get; set; } public string CustomerName { get; set; } public DateTime CreatedDateTime { get; set; } public int? ParentId { get; set; } public virtual Session Parent { get; set; } public virtual ICollection<Message> Messages { get; set; } public virtual ICollection<Session> Sessions { get; set; } }
2- ایجاد ویو مدلهای زیر
public class UserInformation { public string ConnectionId { get; set; } public bool IsOnline { get; set; } public string UserName { get; set; } } public class ChatSessionVm { public string Key { get; set; } public List<string> Value { get; set; } } public class AgentViewModel { public int Id { get; set; } public string CustomerName { get; set; } public int Lenght { get; set; } public DateTimeOffset? Date { get; set; } }
3- ایجاد Hub در سرور
[HubName("chatHub")] public class ChatHub : Microsoft.AspNet.SignalR.Hub { }
listeners متدهای سمت کلاینت
$scope.myHub = new hub("chatHub", { listeners: {}, methods: [] })
$scope.myHub.promise.done(function () { $scope.myHub.init(); $scope.myHub.promise.done(function () { }); });
public void Init() { _chatSessions = _chatSessions ?? (_chatSessions = new List<ChatSessionVm>()); _agents = _agents ?? (_agents = new ConcurrentDictionary<string, UserInformation>()); Clients.Caller.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0); }
5-وضعیت کارشناسان :
$scope.requestChat = function (msg) { if (!defaultCustomerUserName) { //گرفتن کاربر لاگین شده //ما از آرایه تصادفی استفاده میکنیم var nameDefaultArray = [ 'حسین', 'حسن', 'علی', 'عباس', 'زهرا', 'سمیه' ]; defaultCustomerUserName=nameDefaultArray[Math.floor(Math.random() * nameDefaultArray.length)]; } var userName = defaultCustomerUserName; if (!$scope.chatId) { $scope.chatId = sessionStorage.getItem(chatKey); $http.get("http://ipinfo.io") .success(function (response) { $scope.myHub.logVisit(response.city, response.country, msg, userName); }).error(function (e, status, headers, config) { $scope.myHub.logVisit("Tehran", "Ir", msg, userName) }); $scope.myHub.requestChat(msg); $scope.chatTitle = $scope.options.waitingForOperator; $scope.pendingRequestChat = true; } else { $scope.myHub.clientSendMessage(msg, userName); }; $scope.message = ""; };
6-مشاهده تقاضای مکالمه کاربران توسط کارشناسان:
public void AcceptRequestChat(string customerConnectionId, string body, string userName) { var agent = FindAgent(Context.ConnectionId); var session = _chatSessions.FirstOrDefault(item => item.Key.Equals(agent.Key)); if (session == null) { _chatSessions.Add(new ChatSessionVm { Key = agent.Key, Value = new List<string> { customerConnectionId } }); } else { session.Value.Add(customerConnectionId); } Clients.Client(Context.ConnectionId).agentChat(customerConnectionId, body, userName); Clients.Client(customerConnectionId).clientChat(customerConnectionId, agent.Value.UserName); foreach (var item in _agents.Where(item => item.Value.IsOnline)) { Clients.Client(item.Value.ConnectionId).refreshChatWith(agent.Value.UserName, customerConnectionId); } var session = _db.Sessions.Add(new Session { AgentName = agent.Key, CustomerName = userName, CreatedDateTime = DateTime.Now }); _db.SaveChanges(); var message = new Message { CreationTime = DateTime.Now, Sender = agent.Key, Receiver = userName, body=body, Session = session }; _db.Messages.Add(message); _db.SaveChanges(); }
public void CloseChat(string id) { var findAgent = FindAgent(Context.ConnectionId); var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id)); if (session == null) return; Clients.Client(id).clientAddMessage(findAgent.Key, "مکالمه شما با کارشناس مربوطه به اتمام رسیده است"); foreach (var agent in _agents) { Clients.Client(agent.Value.ConnectionId).refreshLeaveChat(agent.Value.UserName, id); } _chatSessions.Remove(session); }
8-انتقال مکالمه مشتری به کارشناسی دیگر
public void EngageVisitor(string newAgentId, string cumtomerId, string customerName,string clientSessionId) { #region remove session of current agent var currentAgent = FindAgent(Context.ConnectionId); var currentSession = _chatSessions.FirstOrDefault(item => item.Value.Contains(cumtomerId)); if (currentSession != null) { _chatSessions.Remove(currentSession); } #endregion #region add session to new agent var newAgent = FindAgent(newAgentId); var newSession = _chatSessions.FirstOrDefault(item => item.Key.Equals(newAgent.Key)); if (newSession == null) { _chatSessions.Add(new ChatSessionVm { Key = newAgent.Key, Value = new List<string> { cumtomerId } }); } else { newSession.Value.Add(cumtomerId); } #endregion Clients.Client(currentAgent.Value.ConnectionId).addMessage(cumtomerId, newAgent.Key, "ادامه مکالمه به کارشناس " + newAgent.Key + "مقابل منتقل شد"); Clients.Client(newAgentId).addMessage(cumtomerId, currentAgent.Key, "لطفا مکالمه را ادامه دهید.با تشکر"); Clients.Client(cumtomerId).clientAddMessage(newAgent.Value.UserName, "مکالمه شما با کارشناس زیر برقرار گردید" + newAgent.Key); var session = _db.Sessions.FirstOrDefault (item => item.AgentName.Equals(currentAgent.Value.UserName) && item.CustomerName.Equals(customerName)); if (session != null) { var sessionId = session.Id; var messages = _db.Messages.Where(item => item.Session.Id.Equals(sessionId)); var result = JsonConvert.SerializeObject(messages, new Formatting(), _settings); Clients.Client(newAgentId).visitorSwitchConversation (Context.ConnectionId, customerName, result, clientSessionId); } foreach (var item in _agents.Where(item => item.Value.IsOnline)) { Clients.Client(item.Value.ConnectionId).refreshChatWith(newAgent.Value.UserName, cumtomerId); } _db.Sessions.Add(new Session { AgentName = newAgent.Key, CustomerName = customerName, CreatedDateTime = DateTime.Now, Parent = _db.Sessions.Where(item => item.AgentName.Equals(currentAgent.Key) && item.CustomerName.Equals(customerName)).OrderByDescending(item => item.Id).FirstOrDefault() }); _db.SaveChanges(); }
var app = angular.module("app", ["SignalR", 'ngRoute', 'ngAnimate', 'ngSanitize', 'mgcrea.ngStrap', 'angular-smilies']); app.config(["$routeProvider", "$provide", "$httpProvider", "$locationProvider", function ($routeProvider, $provide, $httpProvider, $locationProvider) { $routeProvider. when('/', { templateUrl: 'app/views/home.html', controller: "HomeCtrl" }). when('/agent', { templateUrl: 'app/views/agent.html', controller: "ChatCtrl" }) .otherwise({ redirectTo: "/" });; }]); app.controller("HomeCtrl", ["$scope", function ($scope) { $scope.title = "home"; }]) app.controller("ChatCtrl", ["$scope", "Hub", "$location", "$http", "$rootScope", function ($scope, hub, $location, $http, $rootScope) { if (!$scope.myHub) { var chatKey = "angular-signalr"; var defaultCustomerUserName = null; function getid(id) { var find = false; var position = null; angular.forEach($scope.chatConversation, function (index, i) { if (index.id === id && !find) { find = true; position = i; return; } }); return position; } function apply() { $scope.$apply(); } $scope.boxheader = function () { var height = 0; $("#chat-box").slideToggle('slow', function () { if ($("#chat-box-header").css("bottom") === "0px") { height = $("#chat-box").height() + 20; } else { height = 0; } $("#chat-box-header").css("bottom", height); }); }; var init = function () { $scope.agent = { id: "", name: "", isOnline: false }; $rootScope.msg = ""; $scope.alarmStatus = false; $scope.options = { offlineTitle: "آفلاین", onlineTitle: "آنلاین", waitingForOperator: "لطفا منتظر بمانید تا به اپراتور وصل شوید", emailSent: "ایمیل ارسال گردید", emailFailed: "متاسفانه ایمیل ارسال نگردید", logOut: "خروج", setting: "تنظیمات", conversion: "آرشیو", edit: "ویرایش", alarm: "قطع/وصل کردن صدا", complete: "تکمیل", pending: "منتظر ماندن", reject: "عدم پذیرش", lock: "آنلاین شدن", unlock: "آفلاین شدن", alarmOn: "روشن", alarmOff: "خاموش", upload: "آپلود" }; $scope.chatConversation = []; $scope.chatSessions = []; $scope.customerVisit = []; $scope.agentClientMsgs = []; $scope.clientAgentMsg = []; }(); //تعریف هاب به همراه متدهای آن $scope.myHub = new hub("chatHub", { listeners: { "clientChat": function (id, agentName) { $scope.clientAgentMsg.push({ name: agentName, msg: "با سلام در خدمت میباشم" }); $scope.chatTitle = "کارشناس: " + agentName; $scope.pendingRequestChat = false; sessionStorage.setItem(chatKey, id); }, "agentChat": function (id, firstComment, customerName) { var date = new Date(); var position = getid(id); if (position > 0) { $scope.chatSessions[position].length = $scope.chatConversation[position].length + 1; $scope.chatSessions[position].date = date.toISOString(); return; } else { $scope.chatConversation.push({ id: id, sessions: [{ name: customerName, msg: firstComment, date: date }], agentName: $scope.agent.name, customerName: customerName, dateStartChat: date.getHours() + ":" + date.getMinutes(), }); $scope.chatSessions.push({ id: id, length: 1, userName: customerName, date: date.toISOString() }); } sessionStorage.setItem(chatKey, id); apply(); }, //برروز رسانی لیست برای کارشناسان "refreshChatWith": function (agentName, customerConnectionId) { angular.forEach($scope.customerVisit, function (index, i) { if (index.connectionId === customerConnectionId) { $scope.customerVisit[i].chatWith = agentName; } }); apply(); }, //برروز رسانی لیست برای کارشناسان "refreshLeaveChat": function (agentName, customerConnectionId) { angular.forEach($scope.customerVisit, function (index, i) { if (index.connectionId === customerConnectionId) { $scope.customerVisit[i].chatWith =agentName + "---" + " به مکالمه خاتمه داده است "; } }); apply(); } //وضعیت آنلاین بودن کارشناسان , "onlineStatus": function (state) { if (state) { $scope.chatTitle = $scope.options.onlineTitle; $scope.hasOnline = true; $scope.hasOffline = false; } else { $scope.chatTitle = $scope.options.offlineTitle; $scope.hasOffline = true; $scope.hasOnline = false; } $scope.$apply() }, "loginResult": function (status, id, name) { if (status) { $scope.agent.id = id; $scope.agent.name = name; $scope.agent.isOnline = true; $scope.userIsLogin = $scope.agent; $scope.$apply(function () { $location.path("/agent"); }); } else { $scope.agent = null; toastr.error("کارشناسی با این مشخصات وجود ندارد"); return; } }, "newVisit": function (userName, city, country, chatWith, connectionId, firstComment) { var exist = false; angular.forEach($scope.customerVisit, function (index) { if (index.connectionId === connectionId) { exist = true; return; } }); if (!exist) { var date = new Date(); $scope.customerVisit.unshift({ userName: userName, date: date, city: city, country: country, chatWith: chatWith, connectionId: connectionId, firstComment: firstComment }); if ($scope.alarmStatus) { var snd = new Audio("/App/assets/sounds/Sedna.ogg"); snd.play(); } toastr.success("تقاضای جدید دریافت گردید"); apply(); } }, "addMessage": function (id, from, value) { if ($scope.alarmStatus) { var snd = new Audio("/App/assets/sounds/newmsg.mp3"); snd.play(); } $scope.agentUserMsgs = []; var date = new Date(); var position = getid(id); if ($scope.chatConversation.length > 0 && position != null) { $scope.chatConversation[position].sessions.push({ name: from, msg: value, date: date }); } var item = $scope.chatConversation[position]; if (item) { angular.forEach(item.sessions, function (index) { $scope.agentUserMsgs.push({ name: index.name, msg: index.msg, date: date }); }); $scope.chatSessions[position].length = $scope.chatSessions[position].length + 1; } apply(); }, "clientAddMessage": function (id, from) { if ($scope.alarmStatus) { var snd = new Audio("/App/assets/sounds/newmsg.mp3"); snd.play(); } $scope.clientAgentMsg.push({ name: id, msg: from }); apply(); }, "visitorSwitchConversation": function (id, customerName, sessions, sessionId) { sessions = JSON.parse(sessions); var date = new Date(); var sessionList = []; angular.forEach(sessions, function (index) { sessionList.push({ name: index.sender, msg: index.body, date: index.creationTime }); }); $scope.chatConversation.push({ id: sessionId, sessions: sessionList, customerName: customerName, dateStartChat: date.getHours() + ":" + date.getMinutes(), agentName: $scope.agent.name }); $scope.chatSessions.push({ id: sessionId, length: sessions.length, date: date }); }, "receiveTicket": function (items) { angular.forEach(JSON.parse(items), function (index) { $scope.ticketList = []; $scope.ticketList.push(index); }); }, //آرشیو گفته گوهای کارشناس "receiveHistory": function (items) { $scope.agentHistory = []; angular.forEach(JSON.parse(items), function (index) { $scope.agentHistory.push(index); }); apply(); }, //جزییات آرشیو گفتگوها "detailsHistory": function (items) { $scope.historyMsg = []; angular.forEach(JSON.parse(items), function (index) { $scope.historyMsg.push({ name: index.sender, msg: index.body, date: index.creationTime }); }); $("#detailsAgentHistory").modal(); apply(); }, //لیست کارشناسان آنلاین "agentList": function (items) { $scope.agentList = []; angular.forEach(items, function (index) { if ($scope.agent.name != index.Key) { $scope.agentList.push({ name: index.Key, id: index.Value.ConnectionId }); } }); $("#agentList").modal(); apply(); } }, methods: ["agentConnect", "sendTicket", "requestChat", "clientSendMessage", "closeChat", "init", "logVisit", "agentChangeStatus", "engageVisitor", "agentSendMessage", "transfer", "leaveChat", "acceptRequestChat", "leaveChat", "detailsSessoinMessage", "showAgentList", "getAgentHistoryChat" ], errorHandler: function (error) { console.error(error); } }); $scope.myHub.promise.done(function () { $scope.myHub.init(); $scope.myHub.promise.done(function () { }); }); $scope.LeaveChat = function () { $scope.myHub.LeaveChat(); }; $scope.loginAgent = function (userName) { // username :security user username from agent role if (userName == "hossein" || userName == "ali") { $scope.myHub.promise.done(function () { $scope.myHub.agentConnect(userName).then(function (result) { $scope.agent.name = userName; $scope.agent.isOnline = true; }); }); } }; $scope.requestChat = function (msg) { if (!defaultCustomerUserName) { //گرفتن کاربر لاگین شده //ما از آرایه تصادفی استفاده میکنیم var nameDefaultArray = [ 'حسین', 'حسن', 'علی', 'عباس', 'زهرا', 'سمیه' ]; defaultCustomerUserName=nameDefaultArray[Math.floor(Math.random() * nameDefaultArray.length)]; } var userName = defaultCustomerUserName; if (!$scope.chatId) { $scope.chatId = sessionStorage.getItem(chatKey); $http.get("http://ipinfo.io") .success(function (response) { $scope.myHub.logVisit(response.city, response.country, msg, userName); }).error(function (e, status, headers, config) { $scope.myHub.logVisit("Tehran", "Ir", msg, userName) }); $scope.myHub.requestChat(msg); $scope.chatTitle = $scope.options.waitingForOperator; $scope.pendingRequestChat = true; } else { $scope.myHub.clientSendMessage(msg, userName); }; $scope.message = ""; }; $scope.acceptRequestChat = function (customerConnectionId, firstComment, customerName) { $scope.myHub.acceptRequestChat(customerConnectionId, firstComment, customerName); }; $scope.changeAgentStatus = function () { $scope.agent.isOnline = !$scope.agent.isOnline; $scope.myHub.agentChangeStatus($scope.agent.isOnline); }; $scope.detailsChat = function (chatId, userName) { $scope.agentUserMsgs = []; angular.forEach($scope.chatConversation, function (index) { if (index.id === chatId) { $scope.dateStartChat = index.dateStartChat; angular.forEach(index.sessions, function (value) { $scope.agentUserMsgs.push({ name: value.name, msg: value.msg, date: value.date }); }); }; }); $scope.agentChatWithUser = chatId; $scope.customerName = userName; $("#agentUserChat").modal(); }; $scope.ticket = { submit: function () { var name = $scope.ticket.name; var email = $scope.ticket.email; var comment = $scope.ticket.comment; $scope.myHub.sendTicket(name, email, comment); } }; $scope.showHistory = function () { $scope.myHub.getAgentHistoryChat($scope.agent.name); }; $scope.detailsChatHistory = function (id) { $scope.myHub.detailsSessoinMessage(id, $scope.agent.id); }; $scope.agentMsgToUser = function (msg) { var chatId = $scope.agentChatWithUser; var customerName = $scope.customerName; if (!customerName) { angular.forEach($scope.customerVisit, function (index) { if (index.connectionId == chatId) { customerName = index.userName; } }); } if (chatId !== "" && msg !== "") { $scope.myHub.agentSendMessage(chatId, msg, customerName); } //not bind to scope.msg! not correctly work $scope.msg = ""; $("#post-msg").val(""); }; $scope.closeChat = function (chatId) { var item = $scope.chatConversation[getid(chatId)]; $scope.myHub.closeChat(chatId); }; $scope.engageVisitor = function (newAgentId) { var customerId = $scope.customerId; var customerName = $scope.customerName; var clientSessionId = $scope.clientSessionId; $scope.myHub.engageVisitor(newAgentId, customerId, customerName, clientSessionId); $("[data-dismiss=modal]").trigger({ type: "click" }); }; $scope.selectVisitor = function (customerId, customerName, clientSessionId) { $scope.customerId = customerId; $scope.customerName = customerName; $scope.clientSessionId = clientSessionId; $scope.myHub.showAgentList(); }; $scope.setClass = function (item) { if (item === "من") return "question"; else return "response"; }; $scope.setdirectionClass = function (item) { if (item === $scope.agent.name) return { "float": "left" }; else return { "float": "right" }; }; $scope.setArrowClass = function (item) { if (item === $scope.agent.name) return "left-arrow"; else return "right-arrow"; }; $scope.setAlarm = function () { $scope.alarmStatus = !$scope.alarmStatus; }; } }]); app.directive("showtab", function () { return { link: function (scope, element, attrs) { element.click(function (e) { e.preventDefault(); $(element).addClass("active"); $(element).tab("show"); }); } }; }); //زمان ارسال پیام app.directive("timeAgo", function ($q) { return { restrict: "AE", scope: false, link: function (scope, element, attrs) { jQuery.timeago.settings.strings = { prefixAgo: null, prefixFromNow: null, suffixAgo: "پیش", suffixFromNow: "از حالا", seconds: "کمتر از یک دقیقه", minute: "در حدود یک دقیقه", minutes: "%d دقیقه", hour: "حدود یگ ساعت", hours: "حدود %d ساعت ", day: "یک روز", days: "%d روز", month: "حدود یک ماه", months: "%d ماه", year: "حدود یک سال", years: "%d سال", wordSeparator: " ", numbers: [] } var parsedDate = $q.defer(); parsedDate.promise.then(function () { jQuery(element).timeago(); }); attrs.$observe("title", function (newValue) { parsedDate.resolve(newValue); }); } }; });
[HubName("chatHub")] public class ChatHub : Microsoft.AspNet.SignalR.Hub { private readonly ApplicationDbContext _db = new ApplicationDbContext(); private static ConcurrentDictionary<string, UserInformation> _agents; private static List<ChatSessionVm> _chatSessions; private readonly JsonSerializerSettings _settings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), ReferenceLoopHandling = ReferenceLoopHandling.Ignore }; public void Init() { _chatSessions = _chatSessions ?? (_chatSessions = new List<ChatSessionVm>()); _agents = _agents ?? (_agents = new ConcurrentDictionary<string, UserInformation>()); Clients.Caller.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0); } public void AgentConnect(string userName) { //ما برای ساده کردن مقایسه ساده ای انجام دادیم فقط کاربر حسین یا علی میتواند کارشناس باشد if (userName == "hossein" || userName == "ali") { var agent = new UserInformation(); if (_agents.Any(item => item.Key == userName)) { agent = _agents[userName]; agent.ConnectionId = Context.ConnectionId; } else { agent.ConnectionId = Context.ConnectionId; agent.UserName = userName; agent.IsOnline = true; _agents.TryAdd(userName, agent); } Clients.Caller.loginResult(true, agent.ConnectionId, agent.UserName); Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0); } else { Clients.Caller.loginResult(false, null, null); } } public void AgentChangeStatus(bool status) { var agent = _agents.FirstOrDefault(x => x.Value.ConnectionId == Context.ConnectionId).Value; if (agent == null) return; agent.IsOnline = status; Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0); } public void LogVisit(string city, string country, string firstComment, string userName) { foreach (var agent in _agents) { Clients.Client(agent.Value.ConnectionId).newVisit(userName, city, country, null, Context.ConnectionId, firstComment); } } public void AcceptRequestChat(string customerConnectionId, string body, string userName) { var agent = FindAgent(Context.ConnectionId); var session = _chatSessions.FirstOrDefault(item => item.Key.Equals(agent.Key)); if (session == null) { _chatSessions.Add(new ChatSessionVm { Key = agent.Key, Value = new List<string> { customerConnectionId } }); } else { session.Value.Add(customerConnectionId); } Clients.Client(Context.ConnectionId).agentChat(customerConnectionId, body, userName); Clients.Client(customerConnectionId).clientChat(customerConnectionId, agent.Value.UserName); foreach (var item in _agents.Where(item => item.Value.IsOnline)) { Clients.Client(item.Value.ConnectionId).refreshChatWith(agent.Value.UserName, customerConnectionId); } _db.Sessions.Add(new Session { AgentName = agent.Key, CustomerName = userName, CreatedDateTime = DateTime.Now }); _db.SaveChanges(); var message = new Message { CreationTime = DateTime.Now, Sender = agent.Key, Receiver = userName, Body = body, //ConnectionId = _agents.FirstOrDefault(item => item.Value.UserName == userName).Key, Session = _db.Sessions.OrderByDescending(item => item.Id) .FirstOrDefault(item => item.AgentName.Equals(agent.Key) && item.CustomerName.Equals(userName)) }; _db.Messages.Add(message); _db.SaveChanges(); } public void GetAgentHistoryChat(string userName) { var dic = new Dictionary<int, int>(); var lenght = 0; var chats = _db.Sessions.OrderBy(item => item.Id).Include(item => item.Parent) .Where(item => item.AgentName.Equals(userName)).ToList(); foreach (var session in chats) { Result(session, ref lenght); dic.Add(session.Id, lenght); lenght = 0; } if (!chats.Any()) return; var historyResult = chats.Select(item => new AgentViewModel { Id = item.Id, CustomerName = item.CustomerName, Date = item.CreatedDateTime, Lenght = dic.Any(di => di.Key.Equals(item.Id)) ? dic.FirstOrDefault(di => di.Key.Equals(item.Id)).Value : 0, }).OrderByDescending(item => item.Id).ToList(); Clients.Caller.receiveHistory(JsonConvert.SerializeObject(historyResult, new Formatting(), _settings)); } public void DetailsSessoinMessage(int sessionId, string agentId) { var session = _db.Sessions.FirstOrDefault(item => item.Id.Equals(sessionId)); if (session == null) return; var list = new List<Message>(); GetAllMessages(session, list); var result = JsonConvert.SerializeObject(list.OrderBy(item => item.Id), new Formatting(), _settings); Clients.Client(Context.ConnectionId).detailsHistory(result); } public void ClientSendMessage(string body, string userName) { var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(Context.ConnectionId)); if (session == null || session.Key == null) return; var agentId = _agents.FirstOrDefault(item => item.Key.Equals(session.Key)).Value.ConnectionId; Clients.Caller.clientAddMessage("من", body); Clients.Client(agentId).addMessage(Context.ConnectionId, userName, body); var message = new Message { Sender = FindAgent(agentId).Key, Receiver = userName, Body = body, CreationTime = DateTime.Now, Session = FindSession(userName, FindAgent(agentId).Key) }; _db.Messages.Add(message); _db.SaveChanges(); } public void AgentSendMessage(string id, string body, string userName) { var agent = FindAgent(Context.ConnectionId); Clients.Caller.addMessage(id, agent.Value.UserName, body); Clients.Client(id).clientAddMessage(agent.Value.UserName, body); var message = new Message { Sender = agent.Key, Receiver = userName, Body = body, Session = FindSession(agent.Key, userName), CreationTime = DateTime.Now }; _db.Messages.Add(message); _db.SaveChanges(); } public void CloseChat(string id) { var findAgent = FindAgent(Context.ConnectionId); var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id)); if (session == null) return; Clients.Client(id).clientAddMessage(findAgent.Key, "مکالمه شما با کارشناس مربوطه به اتمام رسیده است"); foreach (var agent in _agents) { Clients.Client(agent.Value.ConnectionId).refreshLeaveChat(agent.Value.UserName, id); } _chatSessions.Remove(session); } public void RequestChat(string message) { Clients.Caller.clientAddMessage("من", message); } public void EngageVisitor(string newAgentId, string cumtomerId, string customerName,string clientSessionId) { #region remove session of current agent var currentAgent = FindAgent(Context.ConnectionId); var currentSession = _chatSessions.FirstOrDefault(item => item.Value.Contains(cumtomerId)); if (currentSession != null) { _chatSessions.Remove(currentSession); } #endregion #region add session to new agent var newAgent = FindAgent(newAgentId); var newSession = _chatSessions.FirstOrDefault(item => item.Key.Equals(newAgent.Key)); if (newSession == null) { _chatSessions.Add(new ChatSessionVm { Key = newAgent.Key, Value = new List<string> { cumtomerId } }); } else { newSession.Value.Add(cumtomerId); } #endregion Clients.Client(currentAgent.Value.ConnectionId).addMessage(cumtomerId, newAgent.Key, "ادامه مکالمه به کارشناس " + newAgent.Key + "مقابل منتقل شد"); Clients.Client(newAgentId).addMessage(cumtomerId, currentAgent.Key, "لطفا مکالمه را ادامه دهید.با تشکر"); Clients.Client(cumtomerId).clientAddMessage(newAgent.Value.UserName, "مکالمه شما با کارشناس زیر برقرار گردید" + newAgent.Key); var session = _db.Sessions.FirstOrDefault (item => item.AgentName.Equals(currentAgent.Value.UserName) && item.CustomerName.Equals(customerName)); if (session != null) { var sessionId = session.Id; var messages = _db.Messages.Where(item => item.Session.Id.Equals(sessionId)); var result = JsonConvert.SerializeObject(messages, new Formatting(), _settings); Clients.Client(newAgentId).visitorSwitchConversation (Context.ConnectionId, customerName, result, clientSessionId); } foreach (var item in _agents.Where(item => item.Value.IsOnline)) { Clients.Client(item.Value.ConnectionId).refreshChatWith(newAgent.Value.UserName, cumtomerId); } _db.Sessions.Add(new Session { AgentName = newAgent.Key, CustomerName = customerName, CreatedDateTime = DateTime.Now, Parent = _db.Sessions.Where(item => item.AgentName.Equals(currentAgent.Key) && item.CustomerName.Equals(customerName)).OrderByDescending(item => item.Id).FirstOrDefault() }); _db.SaveChanges(); } public void ShowAgentList() { Clients.Caller.agentList(_agents.ToList()); } public override Task OnDisconnected(bool stopCalled) { var id = Context.ConnectionId; var isAgent = _agents != null && _agents.Any(item => item.Value.ConnectionId.Equals(id)); if (isAgent) { UserInformation agent; var currentAgentConnectionId = FindAgent(id).Key; if (currentAgentConnectionId == null) return base.OnDisconnected(stopCalled); if (_chatSessions.Any()) { var sessions = _chatSessions.FirstOrDefault(item => item.Key.Equals(currentAgentConnectionId)); //اطلاع دادن به تمام کاربرانی که در حال مکالمه با کارشناس هستند if (sessions != null) { var result = sessions.Value.ToList(); for (var i = 0; i < result.Count(); i++) { var localId = result[i]; Clients.Client(localId).clientAddMessage(currentAgentConnectionId, "ارتباط شما با مشاور مورد نظر قطع شده است"); } } } _agents.TryRemove(currentAgentConnectionId, out agent); Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0); Clients.Client(id).loginResult(false, null, null); } else { if (_chatSessions == null || !_chatSessions.Any(item => item.Value.Contains(id) && _agents == null)) return base.OnDisconnected(stopCalled); var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id)); if (session == null) return base.OnDisconnected(stopCalled); var agentName = session.Key; var agent = _agents.FirstOrDefault(item => item.Key.Equals(agentName)); if (agent.Key != null) { Clients.Client(agent.Value.ConnectionId).addMessage(id, "کاربر", "اتصال با کاربر قطع شده است"); } } return base.OnDisconnected(stopCalled); } private KeyValuePair<string, UserInformation> FindAgent(string connectionId) { return _agents.FirstOrDefault(item => item.Value.ConnectionId.Equals(connectionId)); } private Session FindSession(string key, string userName) { return _db.Sessions.Where(item => item.AgentName.Equals(key) && item.CustomerName.Equals(userName)) .OrderByDescending(item => item.Id).FirstOrDefault(); } private static void Result(Session parent, ref int lenght) { while (true) { if (parent == null) return; lenght += parent.Messages.Count(); parent = parent.Parent; } } private static List<Message> GetAllMessages(Session node, List<Message> list) { if (node == null) return null; list.AddRange(node.Messages); if (node.Parent != null) { GetAllMessages(node.Parent, list); } return null; } }
<div> <div> <h2> خوش آمدید <span ng-bind="agent.name"> </span> <a ng-click="changeAgentStatus()"> <i ng-if="changeStatus==null" data-placement="bottom" data-trigger="hover " bs-tooltip="options.lock"></i> <i ng-if="changeStatus==true" data-placement="bottom" data-trigger="hover" bs-tooltip="options.unlock"></i> </a> </h2> <div style="float: left"> <a ng-click="setAlarm()"> <i ng-show="alarmStatus" data-placement="bottom" data-trigger="hover " bs-tooltip="options.alarmOn"></i> <i ng-show="!alarmStatus" data-placement="bottom" data-trigger="hover " bs-tooltip="options.alarmOff"></i> </a> <!--<a data-placement="bottom" data-trigger="hover " bs-tooltip="options.conversion" ng-click="showHistory()"><i></i></a>--> <a data-placement="bottom" data-trigger="hover " bs-tooltip="options.edit"><i></i><span></span></a> <a data-placement="bottom" data-trigger="hover " bs-tooltip="options.setting"><i></i></a> <a data-placement="bottom" data-trigger="hover " bs-tooltip="options.signOut" ng-click="LeaveChat()"><i></i><span></span></a> </div> </div> <div> <div> <div id="chat-content"> <div> <ul> <li> <a showtab href="#online-list">آنلاین</a> </li> <li> <a ng-click="showHistory()" showtab href="#conversation">آرشیو گفتگوها</a> </li> </ul> <div> <div id="online-list"> <div> <h2> <i></i><span></span> <span>نمایش آنلاین مراجعه ها</span> </h2> </div> <div> <div id="agent-chat"> <div id="real-time-visits"> <table id="current-visits"> <thead> <tr> <th>نام کاربر</th> <th>زمان اولین تقاضا</th> <th>منطقه</th> <th>پاسخ</th> </tr> </thead> <tbody> <tr id="{{item.connectionId}}" ng-animate="animate" ng-repeat="item in customerVisit "> <td ng-bind="item.userName"></td> <td> <span time-ago title="{{item.date}}"></span> </td> <td> <span ng-bind="item.country"></span> /<span ng-bind="item.city"> </span> </td> <td> <a style="cursor: pointer" ng-if="item.chatWith== null" ng-click="acceptRequestChat(item.connectionId,item.firstComment,item.userName)"> شروع مکالمه </a> <span ng-if="item.chatWith "> وضعیت: <span>در حال مکالمه با</span> <span ng-bind="item.chatWith"></span> <a ng-show="item.chatWith==agent.name" ng-click="selectVisitor(item.connectionId,item.userName,item.connectionId)"> انتقال مکالمه </a> </span> <ul ng-repeat="session in chatSessions track by $index" style="padding:0px;"> <li ng-if="session.id==item.connectionId" id="{{session.id}}"> <div> <p> تاریخ شروع مکالمه: <span time-ago title="{{session.date}}"></span> </p> <p> تعداد پیام ها: <span ng-bind="session.length"></span> </p> </div> <p> <a ng-click="detailsChat(session.id,session.userName)">جزییات </a> <a ng-click="closeChat(session.id)"> خاتمه عملیات </a> </p> </li> </ul> </td> </tr> </tbody> </table> </div> </div> </div> </div> <div id="conversation"> <div> <h2> <i></i><span></span> <span>آرشیو گفتگوهای </span> {{agent.name}} </h2> </div> <div> <div> <table id="current-visits"> <thead> <tr> <th>شناسه مشتری</th> <th>نام مشتری</th> <th>تعداد محاوره ها</th> <th>تاریخ</th> <th>جزئیات</th> </tr> </thead> <tbody> <tr ng-repeat="item in agentHistory track by $index"> <td ng-bind="item.id"></td> <td ng-bind="item.customerName"></td> <th ng-bind="item.lenght"></th> <td><span time-ago title="{{item.date}}"></span></td> <th> <ang-click="detailsChatHistory(item.id)" >مشاهده جزییات گفتگو</a> </th> </tr> </tbody> </table> </div> </div> </div> </div> </div> </div> </div> </div> <div id="detailsAgentHistory" tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true"> <div> <div> <div> <div> <button type="button" data-dismiss="modal" aria-hidden="true">×</button> </div> <h2> <span></span>تاریخچه گفتگو </h2> </div> <div> <div style="display: block"> <ul ng-repeat="item in historyMsg"> <li> <span ng-bind="item.name" ng-style="setdirectionClass(item.name)"> </span> <span ng-style="setdirectionClass(item.name)"> <span ng-class="setArrowClass(item.name)"></span> <span time-ago title="{{item.date}}"></span> <span> <p ng-bind-html="item.msg | smilies"></p> </span> </span> </li> </ul> </div> </div> </div> </div> </div> <div id="agentList" tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true"> <div> <div> <div> <div> <button type="button" data-dismiss="modal" aria-hidden="true">×</button> </div> <h2> <span></span>لیست تمام کارشناسان </h2> </div> <div> <div style="display: block;"> <div ng-show="agentList.length==0"> کارشناس آنلاینی وجود ندارد </div> <ul ng-repeat="item in agentList"> <li> <span> <a ng-click="engageVisitor(item.id)">{{item.name}}</a> </span> </li> </ul> </div> </div> </div> </div> </div> <div id="agentUserChat" tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true"> <div> <div> <div> <div> <button type="button" data-dismiss="modal" aria-hidden="true">×</button> </div> <h2> <span></span>گفتگو </h2> </div> <div> <div> <div> <div style="display: block;"> <label>شروع چت در </label>: <span ng-bind="dateStartChat"></span> <ul> <li ng-repeat="item in agentUserMsgs"> <span ng-bind="item.name" ng-style="setdirectionClass(item.name)"> </span> <span ng-style="setdirectionClass(item.name)"> <span ng-class="setArrowClass(item.name)"></span> <span time-ago title="{{item.date}}"></span> <span> <p ng-bind-html="item.msg | smilies"></p> </span> </span> </li> </ul> <div> <div> <textarea id="post-msg" ng-model="msg" placeholder="متن خود را وارد نمایید" style="overflow: hidden; word-wrap: break-word; resize: horizontal; height: 80px; max-width: 100%"></textarea> <span smilies-selector="msg" smilies-placement="right" smilies-title="Smilies"></span> </div> <div style="text-align: center; margin-top: 5px"> <button ng-click="agentMsgToUser(msg)">ارسال</button> </div> </div> </div> </div> </div> </div> </div> </div> </div> </div>
<html ng-app="app"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Live Support</title> <link href="~/Content/bootstrap-rtl.css" rel="stylesheet" /> <link href="~/Scripts/smilies/angular-smilies-embed.css" rel="stylesheet" /> <link href="~/Content/font-awesome.css" rel="stylesheet" /> <link href="~/Content/toastr.css" rel="stylesheet" /> <link href="~/Content/liveSupport.css" rel="stylesheet" /> <script src="~/Scripts/jquery-1.10.2.js"></script> <script src="~/Scripts/toastr.js"></script> <script src="~/Scripts/jquery.timeago.js"></script> <script src="~/Scripts/angular.js"></script> <script src="~/Scripts/angular-animate.js"></script> <script src="~/Scripts/angular-sanitize.js"></script> <script src="~/Scripts/angular-route.js"></script> <script src="~/Scripts/angular-strap.js"></script> <script src="~/Scripts/angular-strap.tpl.js"></script> <script src="~/Scripts/smilies/angular-smilies.js"></script> <script src="~/Scripts/jquery.signalR-2.2.0.js"></script> <script src="~/Scripts/angular-signalr-hub.js"></script> <script src="~/app/app.js"></script> @Scripts.Render("~/bundles/bootstrap") </head> <body ng-controller="ChatCtrl"> <div ng-view> </div> <div id="chat-box-header" ng-click="boxheader()"> {{chatTitle}} </div> <div id="chat-box"> <div ng-show="hasOnline"> <div id="style-1" style="min-height:100px;"> <div ng-repeat="item in clientAgentMsg track by $index"> <span ng-class="setClass(item.name)"> {{item.name}} </span> <br /> <p ng-bind-html="item.msg | smilies"></p> </div> </div> <div> <label>پیام</label> <div style="text-align: left; clear: both"> <a data-placement="top" data-trigger="hover " bs-tooltip="options.alarm" ng-click="alarm()"><i></i></a> <a data-placement="top" data-trigger="hover " bs-tooltip="options.signOut" href="signOut()"><i></i><span></span></a> <a data-placement="top" data-trigger="hover " bs-tooltip="options.upload" href="fileupload()"> <span><i></i></span> </a> </div> <div> <textarea style="height: 150px; max-height: 160px;" ng-model="message" placeholder=" متن خود را وارد نمایید"></textarea> <span smilies-selector="message" smilies-placement="right" smilies-title="Smilies"></span> </div> </div> <div style="text-align: center"> <button type="button" ng-disabled="pendingRequestChat" ng-click="requestChat(message)">ارسال </button> </div> </div> <div ng-show="hasOffline"> <div> <form name="Ticket" id="form1"> <fieldset> <div> <label>نام</label> <input name="email" ng-model="ticket.name" > </div> <div> <label>ایمیل</label> <input name="email" ng-model="ticket.email" > </div> <div> <label>پیام</label> </div> <div> <textarea ng-model="ticket.comment" placeholder="متن خود را وارد نمایید"></textarea> <span smilies-selector="ticket.comment" smilies-placement="right" smilies-title="Smilies"></span> </div> </fieldset> <div style="text-align: center"> <button type="button" ng-click="ticket.submit(ticket)"> ارسال </button> </div> </form> </div> </div> </div> </body> </html>
نکات تکمیلی :
app.MapSignalR();