- مقدمة
- العرض
- MVP
- خارطة الطريق
- متطلبات المنتج
- ابدأ الآن
- البيئة المحلية
- البنية المعمارية
- الهيكل
- الأنماط
- الحزمة التقنية
- قاعدة البيانات
- تعدّد المستأجرين
- الفهرس (Catalog)
- العمل دون اتصال
- المصادقة
- التهيئة (Onboarding)
- تدفق التكامل (Integration Flow)
- التهيئة (Provision)
- القبول
- الحضور
- الامتثال التنظيمي
- الملف الشخصي
- الاجتماعات
- إدارة الرسوم
- الفاتورة
- التدويل
- الترجمة
- دليل الترجمة
- أمان قاعدة البيانات
- المساهمة
- هَوْغوارتس
- عرض حي
- الإلهام
البنية
src/components/translation/— the whole feature — one self-contained moduledisplay.ts— getText, getFields — read-time translationactions.ts— translate, autoTranslate, translateText/Fieldsgoogle.ts— translateRaw, translateBatch — provider callsperson.ts— getName, getNames, getLabelssearch.ts— search — bilingual, cache-onlytransliterate.ts— transliterate, formatName — ar→Latinutil.ts— withLang, detectScript, detectLang, fullNametypes.ts— Lang + request/result typesconfig.ts— Google API URL + constants__tests__/actions.test.tsdisplay.test.tsgoogle.test.tsprisma/models/translation.prisma— Translation model (table translation_cache)src/app/api/mobile/translate/route.ts— POST /api/mobile/translatescripts/audit-untranslated.ts— finds DB content rendered without translationكيف يعمل
التدفق هو: خزّن بلغة واحدة ← اختمها بحقل lang ← ترجم عند العرض ← خبّئ النتيجة لكل مدرسة.
الاستدعاء الوحيد الذي تحتاجه: localize()
لأي نموذج في السجل (src/components/translation/registry.ts)، ترجم
صفحة كاملة في تمريرة مُجمَّعة واحدة — بلا استدعاءات لكل حقل، وبلا تمرير schoolId/lang:
// content.tsx — حُلّ على الخادم، وسلّم نصوصًا جاهزة لجدول العميل
import { localize } from "@/components/translation/localize"
const rows = await getAnnouncementsList(schoolId, filters)
const localized = await localize("Announcement", rows) // ← هذا كل شيءتَطوي localize ما كان سابقًا N×M استدعاء getText في
db.translation.findMany واحدة، وتُقدِّم المصطلحات الساخنة من ذاكرة LRU داخلية
(صفر رحلات لقاعدة البيانات)، وتعود إلى النص المصدر إن فشل أي شيء (العرض لا يتعطل أبدًا).
إضافة نموذج محتوى جديد هي إدخال سطر واحد في registry.ts — وبعدها تُترجم كل
قائمة منه تلقائيًا. اقرنها بـ prewarm في مسار الكتابة
(after(() => prewarm("Announcement", row, { schoolId }))) فلا ينتظر أول قارئ
بأي لغة على Google ولا يرى لغة المصدر أبدًا — الترجمة غير مرئية.
تُحلّ lang تلقائيًا من السياق (getDisplayLang() تقرأ ترويسة x-locale)،
فتتوقف عن تمريرها عبر الخصائص. المساعدات أدناه هي اللبنات الأدنى التي بُنيت
عليها localize — لا تلجأ إليها إلا لحقل واحد عابر، أو اسم شخص، أو تسمية عرضية.
أي مساعد أستخدم؟
| لديك… | استخدم | من |
|---|---|---|
| قائمة/كيان من نموذج محتوى | localize / localizeOne (مُجمَّعة، الافتراضي) | localize.ts |
| حقل مخزَّن واحد للعرض | getText | display.ts |
| عدة حقول لكيان واحد | getFields | display.ts |
| اسم شخص | getName | person.ts |
| قائمة صفوف بأسماء | getNames (تزيل التكرار) | person.ts |
| تسميات عشوائية (قاعات، مواد، صفوف) | getLabels (تزيل التكرار) | person.ts |
| كتابة محتوى لقاعدة البيانات | withLang / detectScript | util.ts |
| ترجمة مسبقة عند الكتابة (بلا كمون قراءة) | prewarm | prewarm.ts |
| البحث عبر اللغتين | search | search.ts |
كل قراءة هنا خادم-فقط وتحتاج schoolId (يُحلّ تلقائيًا إن لم تمرره).
حُلّ في content.tsx، ومرّر نصوصًا جاهزة إلى columns.tsx في العميل.
التخزين بلغة واحدة (نموذج البيانات)
كل نموذج محتوى يخزّن النص بلغة واحدة مع حقل lang. أسماء الحقول عامة — أبدًا titleAr/titleEn. لغة التخزين الافتراضية تتبع School.preferredLanguage.
model Announcement {
title String? // اسم عام (ليس titleEn/titleAr)
body String? @db.Text
lang String @default("ar") // اللغة التي خُزِّن بها هذا الصف
}نوع اللغة هو Lang = "en" | "ar" (في src/components/translation/types.ts).
كتابة المحتوى — اختم اللغة
withLang ببساطة تلحق حقل lang بما توشك على حفظه:
// src/components/translation/util.ts
await db.announcement.create({ data: withLang(input, lang) })
// => { ...input, lang }حين لا تملك لغة موثوقة وقت الكتابة، اكتشفها من الحروف الفعلية بـ detectScript — الحرف العربي يفوز، ثم اللاتيني، وإلا "ar". هذا متين عمدًا أمام علم خاطئ (القبول يكتب أسماء لاتينية بـ lang="ar" الافتراضي) وأمام علم غائب:
// src/components/translation/util.ts
detectScript("محمد علي") // "ar"
detectScript("Mohammed Ali") // "en"
detectScript("B102") // "en"تعتمد detectScript على الحروف ذاتها، ولهذا هي الكاشف الصحيح للأسماء ولأي علم lang غير موثوق.
قراءة المحتوى — ترجم عند العرض
getText هي بدائية القراءة الأساسية للقيمة المفردة. إن تطابقت لغتا المحتوى والعرض تعيد النص كما هو؛ وتتحقق من تعارض الحروف (فلا يتشوه صف موسوم خطأ)؛ وإن فشلت الترجمة تعود للنص المصدر — لا تعطّل العرض أبدًا.
// src/components/translation/display.ts
const title = await getText(a.title, a.lang, displayLang, schoolId)لعدة حقول من كيان واحد، تترجمها getFields بالتوازي. لا تستدعِ getText داخل .map() أبدًا — هذا هو N+1 الذي وُجدت الواجهات المُجمَّعة لقتله.
التخبئة — ترجم مرة واحدة لكل مدرسة
الترجمة "المخبأ أولًا". تبحث translate في Translation، وترفع hitCount عند الإصابة، ولا تستدعي Google إلا عند الإخفاق — فكل نص فريد يُدفع ثمنه مرة واحدة لكل مدرسة:
// prisma/models/translation.prisma — على مستوى المدرسة، إزالة تكرار بمفتاح مركّب
model Translation {
schoolId String
sourceText String @db.Text
sourceLanguage String
targetLanguage String
translatedText String @db.Text
provider String @default("google")
hitCount Int @default(0)
lastAccessedAt DateTime @default(now())
@@unique([schoolId, sourceText, sourceLanguage, targetLanguage])
@@index([schoolId, sourceLanguage, targetLanguage])
@@index([lastAccessedAt])
@@map("translation_cache")
}طبقة Google
تغلّف src/components/translation/google.ts واجهة Cloud Translation v2: translateRaw() وtranslateBatch() تقرآن GOOGLE_TRANSLATE_API_KEY (الطبقة المجانية ~500 ألف حرف/شهر). المفتاح الغائب أو المرفوض يرمي خطأً (فتعود getText/translate للمصدر) ويُسجَّل عبر reportTranslationDegraded() المخنوقة — console.error واحدة كحد أقصى كل 5 دقائق. الطلبات محدودة بمهلة 2.5 ثانية (مسار القراءة يفشل سريعًا للمصدر بدل تعليق العرض)، والدفعات تتقسم تلقائيًا (100 مقطع / 4 آلاف حرف)، وإعادة المحاولة للأعطال العابرة اختيارية ({ retry: true }) ويستخدمها prewarm فقط لأنه يعمل خارج مسار الاستجابة.
الأسماء والتسميات — استخدم المساعد القياسي
الأسماء أعلى الحالات ترددًا، فلها وحدة مخصصة — src/components/translation/person.ts. فضّلها على getText للأسماء. تركّب الأجزاء، تكشف الحروف (متجاهلةً علم lang المحتمل خطؤه)، تزيل التكرار لتجنب N+1، و— حصريًا — تنقحر عربي←لاتيني دون اتصال حين تتعطل الواجهة، فيعرض /en المتدهور "Mohammed Ali" لا "محمد علي".
// حُلّ مرة على الخادم، ثم سلّم نصوصًا جاهزة لأعمدة العميل
const names = await getNames(rows, (r) => r.student, lang, schoolId)
const display = names.get(fullName(row.student)) ?? fullName(row.student)
// getName(person, lang, schoolId) — اسم واحد
// getLabels(values, lang, schoolId) — قاعات / مواد / تسميات صفوفهذه المساعدات خادم-فقط (مخبأ قاعدة البيانات + واجهة Google). لجداول
العميل، حُلّ الأسماء في content.tsx الخادمي ومرّر نصوصًا مُترجمة جاهزة إلى
columns.tsx — لا تستدعها من مكوّن عميل أبدًا.
البحث ثنائي اللغة
تبني search (في src/components/translation/search.ts) شروط OR لـ Prisma تطابق الحقل الخام وترجمته المخبأة معًا — فالبحث عن "Ahmed" يجد "أحمد". تقرأ Translation فقط ولا تستدعي الواجهة أبدًا.
واجهة الموبايل
POST /api/mobile/translate تتيح لـ iOS/Android إعادة استخدام المخبأ نفسه بدل بناء خطٍّ ثانٍ. قائمة بيضاء فقط (announcement، assignment، event، exam) وموثَّقة. محتوى المدرسة يبقى مقيدًا بـ schoolId؛ assignment وexam قالبا كتالوج عالميان (بلا عمود schoolId) فيُجلبان بالمعرّف. يفحص المسار المخبأ ليُبلغ بصدق cached: true|false ثم يستدعي translate.
التخبئة والضبط الدقيق
مفتاح المخبأ هو (schoolId, sourceText, sourceLanguage, targetLanguage) — لكل مدرسة عمدًا، و"المخبأ أولًا"، ما يجعل جدول Translation طبقة إزالة التكرار وطبقة التجاوزات معًا.
الضبط الدقيق ممكن اليوم
لأن القراءات تضرب Translation قبل Google، تضبط الترجمة بكتابة الصف الذي تريد: ضع الصياغة المصححة في translatedText وعلّمها (provider: "manual") فلا يكتب فوقها أي استدعاء لاحق — لا prewarm ولا أداة التنظيف تمسّان تجاوزًا يدويًا أبدًا.
لماذا ليس مخبأً عالميًا واحدًا
إسقاط schoolId (مخبأ عالمي) يرفع نسبة الإصابة ويخفض إنفاق Google — لكنه (أ) يخلط محتوى المستأجرين في صف مشترك كاسرًا عزل schoolId الذي يقوم عليه التطبيق كله، و(ب) لا يحمل إلا ترجمة واحدة، فلا يمثل صياغتين مفضلتين لمدرستين مختلفتين. عمّم شريحة عامة إن لزم، لا الكل.
"خبّئ الكلمات المفردة" ← مسرد، لا تقطيع جمل
الترجمة الآلية ليست تركيبية — لا تُترجم جملة بترجمة كلماتها وضمّها (بنك الدم = "blood bank" لا "bank blood"). لكن الحدس يصح لمجموعة محدودة من المصطلحات المتكررة (المواد، تسميات الصفوف، الحالات، رموز القاعات): ترجم كل مصطلح مرة وأعد استخدامه — هذا مسرد. وأعلى الحالات تكرارًا، الأسماء, محلولة أصلًا دون ترجمة آلية: getNames/getLabels تزيلان التكرار وtransliterate تُرومن دون اتصال.
أداء واقعي
إصابة المخبأ بحثٌ مفهرس واحد في الحالين — التعميم يجعل طلبات أكثر تصيب (استدعاءات Google أبطأ أقل)، لا أسرع. مكسب الكمون الحقيقي هو مسرد بالذاكرة للمصطلحات الساخنة (memory-cache.ts — 5000 إدخال، ≤120 حرفًا، صفر رحلات DB عند الإصابة) — وهو مُفعَّل اليوم في translate() وكل ما فوقها.
إيجابيات وسلبيات
لماذا هذا النهج
| الفائدة | التفصيل |
|---|---|
| لا تضخم حقول ثنائية اللغة | حقل title واحد + lang، لا titleAr/titleEn لكل نموذج. صف واحد، مسار كتابة واحد. |
| ترجمة تُدفع مرة | Translation مقيد بالمدرسة مع hitCount؛ كل نص فريد يضرب Google مرة واحدة. |
| تدهور رشيق | getText تعود للمصدر؛ الأسماء تُنقحر عربي←لاتيني دون اتصال. لا واجهة فارغة أبدًا. |
| واعٍ بالحروف | detectScript تثق بالحروف الفعلية، فلا يكسر العرضَ علمُ lang خاطئ أو غائب. |
| تفادي N+1 | localize تطوي الصفحة في findMany واحدة؛ getNames/getLabels تزيلان التكرار للقوائم. |
| بحث عبر اللغتين | الترجمات المخبأة تجعل البحث ثنائي اللغة بصفر تكلفة واجهة. |
| مخبأ واحد للويب والموبايل | واجهة الموبايل تكشف جدول Translation ذاته — لا خط أنابيب مكرر. |
ما الثمن
| الموازنة | التفصيل |
|---|---|
| خادم-فقط + لاتزامني | المساعدات تحتاج schoolId ولا تعمل على العميل؛ يجب الحل في content.tsx. |
| كمون أول عرض | للمحتوى القديم غير المُدفأ: نص غير مخبأ يدفع رحلة Google (محدودة بـ2.5 ث) أول مرة يُعرض — ثم يُخبأ. |
| جودة الترجمة الآلية | ترجمة Google، أضعف ما تكون في أسماء الأعلام؛ النقحرة استدلالية. |
| مورّد واحد + سقف | اعتماد واحد على Google؛ الطبقة المجانية (~500 ألف حرف/شهر) سقف حقيقي؛ مفتاح غائب ← عودة صامتة للمصدر. |
| حالات حدّية للكشف | النصوص مختلطة الحروف تُحسم عربية؛ الرموز القصيرة مثل B102 تُحسم إنجليزية. |
أفضل الممارسات
- اختم
langفي كل كتابة —withLang(data, lang)، أوdetectScript(text)حين لا تكون اللغة موثوقة. - القوائم عبر
localize، الكيان عبرlocalizeOne— وسجّل النموذج فيregistry.ts(سطر واحد؛ يتحققregistry-schema.test.tsمن مطابقة الأعمدة للمخطط). - اقرن كل كتابة بـ
after(() => prewarm(...))— فلا ينتظر أول قارئ باللغة الأخرى أبدًا (حارسPREWARM_BASELINEيفشل الحزمة إن نسيت). - لا تثق بعلم
langالمخزن للأسماء — اكشف من الحروف (مساعدات الأسماء تفعل ذلك أصلًا). - حُلّ على الخادم، اعرض على العميل — ترجم في
content.tsxومرّر نصوصًا جاهزة إلىcolumns.tsx. - ابحث عبر
search— ثنائي اللغة، مخبأ-فقط، بلا تكلفة واجهة. - لا تُعد حقولًا ثنائية اللغة أبدًا (
titleAr/titleEn،nameEn) — حقل عام واحد معlangهو النمط الوحيد. - الموبايل عبر
/api/mobile/translate— أعد استخدام المخبأ، لا تبنِ خطًا ثانيًا. - راقب حوارس التدقيق —
pnpm i18n:audit-contentيسرد الفجوات، وثلاثة اختبارات Vitest تمنع نموها (أسماء خام / ميزات بلا ترجمة / كتابات بلا prewarm). - عند النشر — شغّل
pnpm i18n:backfill(تقرير تكلفة بوضع المعاينة افتراضيًا، ثم--execute) ليقرأ المحتوى القديم بسلاسة من اليوم الأول.
مجالات التحسين
شُحن معظم القائمة الأصلية في تمريرة الجاهزية الإنتاجية 2026-06 (التبني الكامل، prewarm في كل مسار كتابة مدرسي، تقوية المحرك بالمهلة والتقسيم وإعادة المحاولة، سجل مطابق للمخطط، توسيع واجهة الموبايل). المتبقي:
- قابلية رصد التدهور. اليوم فقط
console.errorمخنوقة — أضف إشارة عامة (حقل/api/health) ليرى لوح المراقبة متى يعود/enللمصدر بصمت. - جودة أسماء الأعلام. مسرد / قائمة "لا تُترجم" ومراجعة بشرية للأسماء عالية الظهور.
- تجريد المزوّد. عمود
providerلا يخزن إلا"google"— جرّد العميل ليكون مزوّد ثانٍ ممكنًا. - اتساق المخطط. اختم
langصحيحًا في كل كتابة، فيصبح كشف الحروف شبكة أمان لا الآلية الأساسية. - ترجمة قوالب تحفظ المتغيرات. أجسام
AnnouncementTemplate/NotificationTemplateغير مسجلة عمدًا — الترجمة الآلية تفسد{{placeholders}}.
انظر أيضًا
- التدويل — النظام الآخر: نصوص الواجهة الثابتة (القواميس)، التوجيه، RTL
- الترجمة — متتبع التبني والتغطية لكل منطقة
- تعدد المستأجرين — لماذا تحتاج كل قراءة هنا
schoolId - قاعدة البيانات — حقل
langعلى نماذج المحتوى
On This Page
البنيةكيف يعملالاستدعاء الوحيد الذي تحتاجه:localize()أي مساعد أستخدم؟التخزين بلغة واحدة (نموذج البيانات)كتابة المحتوى — اختم اللغةقراءة المحتوى — ترجم عند العرضالتخبئة — ترجم مرة واحدة لكل مدرسةطبقة Googleالأسماء والتسميات — استخدم المساعد القياسيالبحث ثنائي اللغةواجهة الموبايلالتخبئة والضبط الدقيقالضبط الدقيق ممكن اليوملماذا ليس مخبأً عالميًا واحدًا"خبّئ الكلمات المفردة" ← مسرد، لا تقطيع جملأداء واقعيإيجابيات وسلبياتلماذا هذا النهجما الثمنأفضل الممارساتمجالات التحسينانظر أيضًا