diff --git a/admin/src/locale/index.ts b/admin/src/locale/index.ts index 4d5dd51..a61d4f8 100644 --- a/admin/src/locale/index.ts +++ b/admin/src/locale/index.ts @@ -1,5 +1,8 @@ +import { omniLocales } from './omni.ts'; + const i18n = { 'en-US': { + ...omniLocales['en-US'].messages, 'menu.welcome': 'Welcome', 'menu.dashboard': 'Dashboard', 'menu.list': 'List', @@ -59,6 +62,7 @@ const i18n = { 'navbar.search.placeholder': 'Please search', }, 'zh-CN': { + ...omniLocales['zh-CN'].messages, 'menu.dashboard': '仪表盘', 'menu.list': '列表页', 'menu.result': '结果页', @@ -116,6 +120,9 @@ const i18n = { 'message.lang.tips': '语言切换至 ', 'navbar.search.placeholder': '输入内容查询', }, + 'ar-SA': { + ...omniLocales['ar-SA'].messages, + }, }; export default i18n; diff --git a/admin/src/locale/omni.ts b/admin/src/locale/omni.ts new file mode 100644 index 0000000..afc38eb --- /dev/null +++ b/admin/src/locale/omni.ts @@ -0,0 +1,156 @@ +/** + * OmniCard AI 跨端与管理后台统一多语言国际化字典 + * 严格遵循 AGENTS.md 约束规范: + * 1. 禁止使用任何 emoji + * 2. 纯文本国际化 + * 3. 完整支持阿拉伯语 RTL 镜像布局 + */ + +export type OmniLocale = 'zh-CN' | 'en-US' | 'ar-SA'; +export type Direction = 'ltr' | 'rtl'; + +export interface LocaleMeta { + readonly code: OmniLocale; + readonly name: string; + readonly dir: Direction; +} + +export type OmniMessageKey = + | 'common.confirm' + | 'common.cancel' + | 'common.save' + | 'common.delete' + | 'common.edit' + | 'common.search' + | 'common.loading' + | 'common.success' + | 'common.error' + | 'common.empty' + | 'card.save_contact' + | 'card.exchange' + | 'card.ai_chat' + | 'card.share' + | 'card.scan_nfc' + | 'card.view_profile' + | 'radar.online_visitor' + | 'radar.active_now' + | 'radar.visitor_trace' + | 'radar.lead_score' + | 'crm.contacts' + | 'crm.leads' + | 'crm.follow_up'; + +export interface OmniLocaleConfig { + readonly code: OmniLocale; + readonly name: string; + readonly dir: Direction; + readonly messages: Record; +} + +export const omniLocales: Record = { + 'zh-CN': { + code: 'zh-CN', + name: '简体中文', + dir: 'ltr', + messages: { + 'common.confirm': '确认', + 'common.cancel': '取消', + 'common.save': '保存', + 'common.delete': '删除', + 'common.edit': '编辑', + 'common.search': '搜索', + 'common.loading': '加载中...', + 'common.success': '操作成功', + 'common.error': '操作失败', + 'common.empty': '暂无数据', + 'card.save_contact': '保存到通讯录', + 'card.exchange': '交换名片', + 'card.ai_chat': 'AI 咨询', + 'card.share': '分享名片', + 'card.scan_nfc': 'NFC 触碰感应', + 'card.view_profile': '查看微官网', + 'radar.online_visitor': '在线访客', + 'radar.active_now': '当前活跃', + 'radar.visitor_trace': '访问轨迹', + 'radar.lead_score': '意向度评分', + 'crm.contacts': '联系人列表', + 'crm.leads': '商机线索', + 'crm.follow_up': '跟进记录', + }, + }, + 'en-US': { + code: 'en-US', + name: 'English', + dir: 'ltr', + messages: { + 'common.confirm': 'Confirm', + 'common.cancel': 'Cancel', + 'common.save': 'Save', + 'common.delete': 'Delete', + 'common.edit': 'Edit', + 'common.search': 'Search', + 'common.loading': 'Loading...', + 'common.success': 'Success', + 'common.error': 'Error occurred', + 'common.empty': 'No data available', + 'card.save_contact': 'Save Contact', + 'card.exchange': 'Exchange Card', + 'card.ai_chat': 'AI Assistant', + 'card.share': 'Share Card', + 'card.scan_nfc': 'NFC Tap', + 'card.view_profile': 'View Profile', + 'radar.online_visitor': 'Online Visitors', + 'radar.active_now': 'Active Now', + 'radar.visitor_trace': 'Visitor Trail', + 'radar.lead_score': 'Lead Score', + 'crm.contacts': 'Contacts', + 'crm.leads': 'Leads', + 'crm.follow_up': 'Follow-up', + }, + }, + 'ar-SA': { + code: 'ar-SA', + name: 'العربية', + dir: 'rtl', + messages: { + 'common.confirm': 'تأكيد', + 'common.cancel': 'إلغاء', + 'common.save': 'حفظ', + 'common.delete': 'حذف', + 'common.edit': 'تعديل', + 'common.search': 'بحث', + 'common.loading': 'جاري التحميل...', + 'common.success': 'تمت العملية بنجاح', + 'common.error': 'حدث خطأ', + 'common.empty': 'لا توجد بيانات', + 'card.save_contact': 'حفظ جهة الاتصال', + 'card.exchange': 'تبادل البطاقات', + 'card.ai_chat': 'مساعد الذكاء الاصطناعي', + 'card.share': 'مشاركة البطاقة', + 'card.scan_nfc': 'مسح NFC', + 'card.view_profile': 'عرض الملف الشخصي', + 'radar.online_visitor': 'الزوار الحاليون', + 'radar.active_now': 'نشط الآن', + 'radar.visitor_trace': 'مسار الزائر', + 'radar.lead_score': 'درجة العميل المحتمل', + 'crm.contacts': 'جهات الاتصال', + 'crm.leads': 'العملاء المحتملون', + 'crm.follow_up': 'المتابعة', + }, + }, +}; + +/** + * 判断指定语言是否需要 RTL 镜像排版 + */ +export function isRtl(locale: OmniLocale): boolean { + return omniLocales[locale]?.dir === 'rtl'; +} + +/** + * 获取翻译文本,若不存在则回退至英文或 key 本身 + */ +export function getOmniText(locale: OmniLocale, key: OmniMessageKey): string { + const dict = omniLocales[locale] || omniLocales['en-US']; + return dict.messages[key] || omniLocales['en-US'].messages[key] || key; +} diff --git a/admin/src/style/tokens.ts b/admin/src/style/tokens.ts new file mode 100644 index 0000000..431d116 --- /dev/null +++ b/admin/src/style/tokens.ts @@ -0,0 +1,140 @@ +/** + * OmniCard AI 设计 Token 系统 (Admin & Web) + * 严格遵循 AGENTS.md 约束规范 + * 瑞士国际主义 + 现代 SaaS + 编辑感 + */ + +export interface ColorTokens { + readonly primary: string; + readonly primarySoft: string; + readonly ai: string; + readonly bg: string; + readonly surface: string; + readonly border: string; + readonly text: string; + readonly text2: string; + readonly success: string; + readonly warning: string; + readonly error: string; +} + +export const lightColors: ColorTokens = { + primary: '#0B5CFF', + primarySoft: '#EAF1FF', + ai: '#7C5CFF', + bg: '#FFFFFF', + surface: '#F7F8FA', + border: '#E3E7ED', + text: '#101828', + text2: '#667085', + success: '#12B76A', + warning: '#F79009', + error: '#F04438', +} as const; + +export const darkColors: ColorTokens = { + primary: '#4D8DFF', + primarySoft: '#102A4D', + ai: '#9B8CFF', + bg: '#0B0D10', + surface: '#12151A', + border: '#262B33', + text: '#F2F4F7', + text2: '#98A2B3', + success: '#32D583', + warning: '#FDB022', + error: '#F97066', +} as const; + +export const spacing = { + 4: '4px', + 8: '8px', + 12: '12px', + 16: '16px', + 24: '24px', + 32: '32px', + 48: '48px', + 64: '64px', +} as const; + +export const radius = { + button: '12px', + card: '16px', + modal: '20px', +} as const; + +export const typography = { + fontFamily: 'Inter, Noto Sans SC, Noto Sans Arabic, Noto Sans JP, Noto Sans KR, sans-serif', + fontSize: { + 12: '12px', + 14: '14px', + 16: '16px', + 20: '20px', + 24: '24px', + 32: '32px', + 40: '40px', + }, + lineHeight: { + body: 1.6, + title: 1.25, + arabic: 1.7, + }, + shadow: '0 1px 2px rgba(16,24,40,0.04)', + maxWidth: { + content: '1200px', + article: '720px', + }, + motion: { + micro: '150ms', + drawer: '250ms', + page: '400ms', + easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)', + }, +} as const; + +/** + * 获取指定主题下的 CSS 变量键值对 + */ +export function getCssVariables(theme: 'light' | 'dark' = 'light'): Record { + const colors = theme === 'dark' ? darkColors : lightColors; + + return { + '--omni-color-primary': colors.primary, + '--omni-color-primary-soft': colors.primarySoft, + '--omni-color-ai': colors.ai, + '--omni-color-bg': colors.bg, + '--omni-color-surface': colors.surface, + '--omni-color-border': colors.border, + '--omni-color-text': colors.text, + '--omni-color-text-2': colors.text2, + '--omni-color-success': colors.success, + '--omni-color-warning': colors.warning, + '--omni-color-error': colors.error, + + '--omni-space-4': spacing[4], + '--omni-space-8': spacing[8], + '--omni-space-12': spacing[12], + '--omni-space-16': spacing[16], + '--omni-space-24': spacing[24], + '--omni-space-32': spacing[32], + '--omni-space-48': spacing[48], + '--omni-space-64': spacing[64], + + '--omni-radius-button': radius.button, + '--omni-radius-card': radius.card, + '--omni-radius-modal': radius.modal, + + '--omni-font-family': typography.fontFamily, + '--omni-shadow': typography.shadow, + }; +} + +/** + * 将 CSS 变量对象序列化为 CSS 样式声明字符串 + */ +export function getCssVariableDeclarations(theme: 'light' | 'dark' = 'light'): string { + const vars = getCssVariables(theme); + return Object.entries(vars) + .map(([k, v]) => `${k}: ${v};`) + .join(' '); +} diff --git a/admin/tests/tokens-and-locale.test.ts b/admin/tests/tokens-and-locale.test.ts new file mode 100644 index 0000000..ac289b4 --- /dev/null +++ b/admin/tests/tokens-and-locale.test.ts @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { lightColors, darkColors, spacing, radius, typography, getCssVariables } from '../src/style/tokens.ts'; +import { omniLocales, isRtl, getOmniText } from '../src/locale/omni.ts'; +import defaultI18n from '../src/locale/index.ts'; + +test('1. admin/src/style/tokens.ts - 颜色 Token 校验 (Light & Dark)', () => { + // Light 模式颜色 + assert.equal(lightColors.primary, '#0B5CFF'); + assert.equal(lightColors.primarySoft, '#EAF1FF'); + assert.equal(lightColors.ai, '#7C5CFF'); + assert.equal(lightColors.bg, '#FFFFFF'); + assert.equal(lightColors.surface, '#F7F8FA'); + assert.equal(lightColors.border, '#E3E7ED'); + assert.equal(lightColors.text, '#101828'); + assert.equal(lightColors.text2, '#667085'); + assert.equal(lightColors.success, '#12B76A'); + assert.equal(lightColors.warning, '#F79009'); + assert.equal(lightColors.error, '#F04438'); + + // Dark 模式颜色 + assert.equal(darkColors.primary, '#4D8DFF'); + assert.equal(darkColors.primarySoft, '#102A4D'); + assert.equal(darkColors.ai, '#9B8CFF'); + assert.equal(darkColors.bg, '#0B0D10'); + assert.equal(darkColors.surface, '#12151A'); + assert.equal(darkColors.border, '#262B33'); + assert.equal(darkColors.text, '#F2F4F7'); + assert.equal(darkColors.text2, '#98A2B3'); + assert.equal(darkColors.success, '#32D583'); + assert.equal(darkColors.warning, '#FDB022'); + assert.equal(darkColors.error, '#F97066'); +}); + +test('2. admin/src/style/tokens.ts - 间距、圆角与排版校验', () => { + // 间距 4, 8, 12, 16, 24, 32, 48, 64 + assert.equal(spacing[4], '4px'); + assert.equal(spacing[8], '8px'); + assert.equal(spacing[12], '12px'); + assert.equal(spacing[16], '16px'); + assert.equal(spacing[24], '24px'); + assert.equal(spacing[32], '32px'); + assert.equal(spacing[48], '48px'); + assert.equal(spacing[64], '64px'); + + // 圆角 button 12px, card 16px, modal 20px + assert.equal(radius.button, '12px'); + assert.equal(radius.card, '16px'); + assert.equal(radius.modal, '20px'); + + // 阴影与排版 + assert.equal(typography.shadow, '0 1px 2px rgba(16,24,40,0.04)'); + assert.equal(typography.lineHeight.body, 1.6); + assert.equal(typography.lineHeight.title, 1.25); + assert.equal(typography.lineHeight.arabic, 1.7); +}); + +test('3. admin/src/style/tokens.ts - CSS 变量生成辅助函数', () => { + const lightVars = getCssVariables('light'); + assert.ok(lightVars['--omni-color-primary'] === '#0B5CFF'); + assert.ok(lightVars['--omni-color-bg'] === '#FFFFFF'); + assert.ok(lightVars['--omni-radius-card'] === '16px'); + + const darkVars = getCssVariables('dark'); + assert.ok(darkVars['--omni-color-primary'] === '#4D8DFF'); + assert.ok(darkVars['--omni-color-bg'] === '#0B0D10'); +}); + +test('4. admin/src/locale/omni.ts - 多语言字典与 RTL 支持', () => { + const locales = ['zh-CN', 'en-US', 'ar-SA'] as const; + for (const loc of locales) { + assert.ok(omniLocales[loc], `应当包含语言包: ${loc}`); + assert.ok(omniLocales[loc].messages['card.save_contact']); + assert.ok(omniLocales[loc].messages['card.exchange']); + assert.ok(omniLocales[loc].messages['card.ai_chat']); + assert.ok(omniLocales[loc].messages['radar.online_visitor']); + assert.ok(omniLocales[loc].messages['common.confirm']); + assert.ok(omniLocales[loc].messages['common.cancel']); + } + + // 阿拉伯语 RTL 校验 + assert.equal(omniLocales['ar-SA'].dir, 'rtl'); + assert.equal(isRtl('ar-SA'), true); + assert.equal(omniLocales['zh-CN'].dir, 'ltr'); + assert.equal(isRtl('zh-CN'), false); + assert.equal(isRtl('en-US'), false); + + // 获取文案辅助函数 + assert.equal(getOmniText('zh-CN', 'card.save_contact'), '保存到通讯录'); + assert.equal(getOmniText('en-US', 'card.save_contact'), 'Save Contact'); + assert.equal(getOmniText('ar-SA', 'card.save_contact'), 'حفظ جهة الاتصال'); + + // 禁止包含任何 emoji 校验 + const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u; + for (const loc of locales) { + for (const [key, val] of Object.entries(omniLocales[loc].messages)) { + assert.ok(!emojiRegex.test(val), `语言 ${loc} 的词条 ${key} 严禁含有 emoji: ${val}`); + } + } +}); + +test('5. mpuniapp/common/tokens.uts - uni-app x 跨端 Token 校验', () => { + const utsPath = path.resolve('m:/2026项目/CRMmp/mpuniapp/common/tokens.uts'); + assert.ok(fs.existsSync(utsPath), 'tokens.uts 文件必须存在'); + + const content = fs.readFileSync(utsPath, 'utf-8'); + + // 必须导出 utsLightColors, utsDarkColors, utsSpacing, utsRadius, utsTypography + assert.ok(content.includes('export const utsLightColors: UtsColorTokens =')); + assert.ok(content.includes('export const utsDarkColors: UtsColorTokens =')); + assert.ok(content.includes('export const utsSpacing: UtsSpacing =')); + assert.ok(content.includes('export const utsRadius: UtsRadius =')); + assert.ok(content.includes('export const utsTypography: UtsTypography =')); + + // 校验关键值 + assert.ok(content.includes("'#0B5CFF'")); + assert.ok(content.includes("'#4D8DFF'")); + assert.ok(content.includes("'#7C5CFF'")); + assert.ok(content.includes("'#9B8CFF'")); + assert.ok(content.includes("button: '12px'")); + assert.ok(content.includes("card: '16px'")); + assert.ok(content.includes("modal: '20px'")); + assert.ok(content.includes("space16: '16px'")); +}); + +test('6. admin/src/locale/index.ts - 成功合并 omni 词条且支持 ar-SA', () => { + assert.ok(defaultI18n['zh-CN']['card.save_contact'] === '保存到通讯录'); + assert.ok(defaultI18n['en-US']['card.save_contact'] === 'Save Contact'); + assert.ok(defaultI18n['ar-SA']['card.save_contact'] === 'حفظ جهة الاتصال'); + assert.ok(defaultI18n['ar-SA']['radar.online_visitor'] === 'الزوار الحاليون'); +}); + + diff --git a/mpuniapp/common/tokens.uts b/mpuniapp/common/tokens.uts new file mode 100644 index 0000000..e9ac4cb --- /dev/null +++ b/mpuniapp/common/tokens.uts @@ -0,0 +1,90 @@ +/** + * OmniCard AI - uni-app x (uts) 全局设计 Token 常量定义 + * 严格遵循 AGENTS.md 约束规范与 uts 强类型规范 + */ + +export type UtsColorTokens = { + primary: string + primarySoft: string + ai: string + bg: string + surface: string + border: string + text: string + text2: string + success: string + warning: string + error: string +} + +export const utsLightColors: UtsColorTokens = { + primary: '#0B5CFF', + primarySoft: '#EAF1FF', + ai: '#7C5CFF', + bg: '#FFFFFF', + surface: '#F7F8FA', + border: '#E3E7ED', + text: '#101828', + text2: '#667085', + success: '#12B76A', + warning: '#F79009', + error: '#F04438' +} + +export const utsDarkColors: UtsColorTokens = { + primary: '#4D8DFF', + primarySoft: '#102A4D', + ai: '#9B8CFF', + bg: '#0B0D10', + surface: '#12151A', + border: '#262B33', + text: '#F2F4F7', + text2: '#98A2B3', + success: '#32D583', + warning: '#FDB022', + error: '#F97066' +} + +export type UtsSpacing = { + space4: string + space8: string + space12: string + space16: string + space24: string + space32: string + space48: string + space64: string +} + +export const utsSpacing: UtsSpacing = { + space4: '4px', + space8: '8px', + space12: '12px', + space16: '16px', + space24: '24px', + space32: '32px', + space48: '48px', + space64: '64px' +} + +export type UtsRadius = { + button: string + card: string + modal: string +} + +export const utsRadius: UtsRadius = { + button: '12px', + card: '16px', + modal: '20px' +} + +export type UtsTypography = { + fontFamily: string + shadow: string +} + +export const utsTypography: UtsTypography = { + fontFamily: 'Inter, Noto Sans SC, Noto Sans Arabic, Noto Sans JP, Noto Sans KR, sans-serif', + shadow: '0 1px 2px rgba(16,24,40,0.04)' +}