双语添加

This commit is contained in:
ZuoZuo 2026-03-31 18:04:36 +08:00
parent eef33f87c2
commit 794cf32033
13 changed files with 6403 additions and 17 deletions

261
README.md Normal file
View File

@ -0,0 +1,261 @@
# Frontend
`Frontend/` 是 AppPlatform 的前端工作区,采用 `pnpm workspace + Turbo` 的 monorepo 组织方式。当前目录同时承载主业务应用、能力演示、文档站、Mock 服务以及一组共享包和工程脚本。
项目整体基于 Vue 3 技术栈,并延续 `Vue Vben Admin v5` 的分层方式做业务化扩展。
## 1. 技术栈概览
- Vue 3
- Vite
- TypeScript
- Pinia
- Vue Router
- Turbo
- pnpm workspace
- VitePress
- Playwright
- Nitro Mock
- Ant Design Vue / antdv-next
## 2. 工作区结构
```text
Frontend/
├── apps/ 主业务应用(数据平台)
├── playground/ 示例应用、能力演示、E2E 验证入口
├── docs/ VitePress 文档站
├── backend-mock/ Nitro Mock 服务
├── packages/ 业务与框架共享包
├── internal/ 内部工程配置与构建基础设施
├── scripts/ CLI 与部署脚本
├── turbo.json Turbo 任务编排
├── pnpm-workspace.yaml Workspace 包范围定义
└── package.json 根脚本与统一依赖入口
```
## 3. 关键目录说明
### 3.1 apps
主业务前端应用,包名为 `@vben/web-antdv-next`
- 应用标题默认配置为“数据平台”
- 开发端口来自 `apps/.env.development`,默认是 `5999`
- 开发态通过 Vite 代理将 `/console` 转发到 `http://127.0.0.1:2700`
- 入口在 `apps/src/main.ts`
- 启动装配逻辑在 `apps/src/bootstrap.ts`
`apps/src` 主要分层如下:
- `api/core`:较新的核心接口封装
- `api/legacy`:历史业务接口封装
- `router`:路由与权限守卫
- `store`:状态管理
- `layouts`:布局入口
- `views/*`:按业务域组织的页面模块
- `components`:应用级组件
- `adapter`:表单、组件等适配层
- `locales`:国际化资源
### 3.2 playground
演示与验证用应用,包名为 `@vben/playground`
- 默认开发端口为 `5555`
- 开发态接口前缀为 `/api`
- 代理目标为 `http://localhost:5320/api`
- 默认开启 `VITE_NITRO_MOCK=true`
- 内置 Playwright E2E配置位于 `playground/playwright.config.ts`
适合用于:
- 验证基础能力包是否可用
- 编写和运行 E2E 用例
- 调试通用组件、布局、示例页面
### 3.3 docs
文档站应用,包名为 `@vben/docs`,基于 VitePress。
- 配置入口在 `docs/.vitepress/config/index.mts`
- 同时提供中文与英文站点
- 首页内容位于 `docs/src/index.md``docs/src/en/index.md`
### 3.4 backend-mock
Mock 服务包名为 `@vben/backend-mock`,基于 Nitro。
- 默认端口来自 `backend-mock/.env`,当前为 `5320`
- 提供前端开发期所需的接口、鉴权、上传等模拟能力
- 按现有说明,通常由开发流程集成启用,不一定需要单独常驻手动启动
### 3.5 packages
共享包分为几层:
- `packages/@core/base/*`
- 底层基础能力,如 `design``icons``shared``typings`
- 约束:`base` 层“请勿引入 workspace 依赖”
- `packages/@core/ui-kit/*`
- 通用 UI 原子/组合能力,如 `form-ui``layout-ui``menu-ui``popup-ui``tabs-ui``shadcn-ui`
- `packages/@core/composables`
- 通用组合式能力
- `packages/@core/preferences`
- 偏好设置相关底层能力
- `packages/effects/*`
- 偏应用层的组合能力,如 `access``common-ui``hooks``layouts``plugins``request`
- `packages/constants|icons|locales|preferences|stores|styles|types|utils`
- 面向应用的共享常量、样式、类型、工具与状态能力
### 3.6 internal
内部工程基础设施,不直接承载业务页面:
- `internal/lint-configs/*`ESLint / Stylelint 共享配置
- `internal/tsconfig`TS 配置基线
- `internal/vite-config`Vite 配置封装
- `internal/tailwind-config`Tailwind 配置
- `internal/node-utils`Node 侧工具能力
### 3.7 scripts
工程脚本目录:
- `scripts/turbo-run`
- 交互式运行 monorepo 中具备相同脚本的包
- `scripts/vsh`
- 工程巡检工具包含依赖检查、循环依赖扫描、发布检查、lint 封装等能力
- `scripts/deploy`
- Docker / Nginx 部署相关脚本
## 4. 安装与启动
### 4.1 环境要求
- Node.js: `^20.19.0 || ^22.18.0 || ^24.0.0`
- pnpm: `>=10.0.0`
推荐先启用 Corepack
```bash
corepack enable
pnpm install
```
项目根目录限制使用 `pnpm``npm/yarn` 不作为默认包管理器。
### 4.2 常用开发命令
`Frontend/` 目录执行:
```bash
# 交互式选择带 dev 脚本的包启动
pnpm dev
# 启动主业务应用
pnpm dev:antdv-next
# 启动 playground
pnpm dev:play
# 启动文档站
pnpm dev:docs
```
### 4.3 构建命令
```bash
# 构建整个 workspace
pnpm build
# 构建主应用
pnpm build:antdv-next
# 构建 playground
pnpm build:play
# 构建文档站
pnpm build:docs
# 本地构建 Docker 镜像
pnpm build:docker
```
### 4.4 质量与测试命令
```bash
# lint / format
pnpm lint
pnpm format
# 类型检查、依赖检查、循环依赖检查、拼写检查
pnpm check
# 单元测试
pnpm test:unit
# E2E 测试
pnpm test:e2e
```
## 5. 环境变量与运行时约定
### 5.1 apps
`apps/.env``apps/.env.*` 中常见变量:
- `VITE_APP_TITLE`:应用标题
- `VITE_APP_NAMESPACE`本地缓存、store 等命名空间前缀
- `VITE_APP_STORE_SECURE_KEY`:持久化加密密钥
- `VITE_PORT`:开发端口
- `VITE_GLOB_API_URL`:接口基地址
- `VITE_NITRO_MOCK`:是否启用 Nitro Mock
当前开发环境关键值:
- 端口:`5999`
- 接口:`http://127.0.0.1:2700/console`
- Mock`false`
### 5.2 playground
当前开发环境关键值:
- 端口:`5555`
- 接口:`/api`
- Mock`true`
### 5.3 backend-mock
- `PORT=5320`
- `ACCESS_TOKEN_SECRET`
- `REFRESH_TOKEN_SECRET`
## 6. 全局工程约定
- 根命令统一从 `Frontend/package.json` 发起,避免在子包内各自维护重复脚本
- `pnpm dev``pnpm preview` 通过 `turbo-run` 做交互式过滤,不是“启动所有应用”
- Turbo 任务编排定义在 `turbo.json`
- workspace 范围定义在 `pnpm-workspace.yaml`
- `.npmrc` 已配置镜像源与 peer 依赖策略,默认使用 `https://registry.npmmirror.com`
- `@vben-core/base` 层保持最底层,不应反向依赖其它 workspace 包
- 主应用通过偏好设置系统按 `namespace + version + env` 隔离本地缓存与持久化数据
## 7. 建议的阅读顺序
第一次接手该目录,建议按下面顺序阅读:
1. `package.json`
2. `pnpm-workspace.yaml`
3. `turbo.json`
4. `apps/package.json`
5. `apps/src/main.ts`
6. `apps/src/bootstrap.ts`
7. `playground/package.json`
8. `backend-mock/README.md`
## 8. 补充说明
- 现有 `README.zh-CN.md` 仅保留了最基础的安装与运行说明
- 本文件用于补充 `Frontend/` 的全局结构、运行方式与分层约定,便于后续开发、交接与排障

View File

@ -15,6 +15,7 @@ import SysOriginSelect from '#/components/sys-origin-select.vue';
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { setupRuntimeLocaleSync } from './locales/runtime';
import { router } from './router';
import './styles/table-filters.css';
@ -68,12 +69,13 @@ async function bootstrap(namespace: string) {
if (preferences.app.dynamicTitle) {
const routeTitle = router.currentRoute.value.meta?.title;
const pageTitle =
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
(routeTitle ? `${$t(routeTitle)} - ` : '') + $t(preferences.app.name);
useTitle(pageTitle);
}
});
app.mount('#app');
setupRuntimeLocaleSync();
}
export { bootstrap };

View File

@ -6,7 +6,7 @@ import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
const appName = computed(() => preferences.app.name);
const appName = computed(() => $t(preferences.app.name));
const logo = computed(() => preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
</script>

View File

@ -20,6 +20,7 @@ import dayjs from 'dayjs';
const antdLocale = ref<Locale>(antdDefaultLocale);
const modules = import.meta.glob('./langs/**/*.json');
const runtimeModules = import.meta.glob('./langs/*/runtime.json');
const localesMap = loadLocalesMapFromDir(
/\.\/langs\/([^/]+)\/(.*)\.json$/,
@ -31,11 +32,26 @@ const localesMap = loadLocalesMapFromDir(
* @param lang
*/
async function loadMessages(lang: SupportedLanguagesType) {
const [appLocaleMessages] = await Promise.all([
const [appLocaleMessages, runtimeMessages] = await Promise.all([
localesMap[lang]?.(),
loadRuntimeMessages(lang),
loadThirdPartyMessage(lang),
]);
return appLocaleMessages?.default;
const appMessages = appLocaleMessages?.default ?? {};
const { runtime: _runtime, ...rest } = appMessages;
return {
...(runtimeMessages ?? {}),
...rest,
};
}
async function loadRuntimeMessages(lang: SupportedLanguagesType) {
const runtimeLoader = runtimeModules[`./langs/${lang}/runtime.json`];
const runtimeMessages = runtimeLoader
? ((await runtimeLoader()) as { default?: Record<string, string> })
: undefined;
return runtimeMessages?.default;
}
/**

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

267
apps/src/locales/runtime.ts Normal file
View File

@ -0,0 +1,267 @@
import { nextTick, watch } from 'vue';
import { i18n } from '@vben/locales';
const CHINESE_RE = /[\u3400-\u9fff]/;
const SKIP_ATTRIBUTE_SELECTOR =
'[data-no-runtime-locale], .ace_editor, .monaco-editor, noscript, script, style';
const SKIP_TEXT_SELECTOR = `${SKIP_ATTRIBUTE_SELECTOR}, [contenteditable="true"], code, pre`;
const TRANSLATABLE_ATTRIBUTES = [
'alt',
'aria-label',
'placeholder',
'title',
] as const;
const elementOriginals = new WeakMap<Element, Map<string, string>>();
const textOriginals = new WeakMap<Text, string>();
let initialized = false;
let observer: MutationObserver | undefined;
let runtimeMatcherCache:
| {
dict: Record<string, string>;
locale: string;
pattern?: RegExp;
}
| undefined;
function escapeRegExp(text: string) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getRuntimeDictionary(locale: string) {
const messages = i18n.global.getLocaleMessage(locale) as Record<string, unknown>;
const dictionary: Record<string, string> = {};
for (const [key, value] of Object.entries(messages)) {
if (typeof value === 'string' && CHINESE_RE.test(key)) {
dictionary[key] = value;
}
}
return dictionary;
}
function getRuntimeMatcher(locale: string) {
if (runtimeMatcherCache?.locale === locale) {
return runtimeMatcherCache;
}
const dict = locale === 'en-US' ? getRuntimeDictionary(locale) : {};
const keys = Object.keys(dict).sort((left, right) => right.length - left.length);
runtimeMatcherCache = {
dict,
locale,
pattern:
keys.length > 0
? new RegExp(keys.map((key) => escapeRegExp(key)).join('|'), 'g')
: undefined,
};
return runtimeMatcherCache;
}
function getElementOriginalMap(element: Element) {
let originalMap = elementOriginals.get(element);
if (!originalMap) {
originalMap = new Map<string, string>();
elementOriginals.set(element, originalMap);
}
return originalMap;
}
function shouldSkipElement(element: Element) {
return element.matches(SKIP_ATTRIBUTE_SELECTOR) || !!element.closest(SKIP_ATTRIBUTE_SELECTOR);
}
function shouldSkipTextNode(node: Text) {
return !!node.parentElement?.closest(SKIP_TEXT_SELECTOR);
}
function getCurrentLocale() {
return String(i18n.global.locale.value);
}
function translateText(text: string) {
if (!text || !CHINESE_RE.test(text)) {
return text;
}
const locale = getCurrentLocale();
if (locale !== 'en-US') {
return text;
}
const { dict, pattern } = getRuntimeMatcher(locale);
if (!pattern) {
return text;
}
return text.replace(pattern, (matched) => dict[matched] ?? matched);
}
function translateElementAttributes(element: Element) {
if (shouldSkipElement(element)) {
return;
}
const locale = getCurrentLocale();
const originals = getElementOriginalMap(element);
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
const rawValue = element.getAttribute(attribute);
if (rawValue == null) {
continue;
}
if (locale === 'en-US') {
const originalValue = CHINESE_RE.test(rawValue)
? rawValue
: (originals.get(attribute) ?? rawValue);
if (!CHINESE_RE.test(originalValue)) {
continue;
}
const translatedValue = translateText(originalValue);
if (translatedValue !== originalValue) {
originals.set(attribute, originalValue);
if (rawValue !== translatedValue) {
element.setAttribute(attribute, translatedValue);
}
}
continue;
}
const originalValue = originals.get(attribute);
if (originalValue !== undefined && rawValue !== originalValue) {
element.setAttribute(attribute, originalValue);
}
}
}
function translateTextNode(node: Text) {
if (shouldSkipTextNode(node)) {
return;
}
const rawValue = node.nodeValue ?? '';
const locale = getCurrentLocale();
if (locale === 'en-US') {
const originalValue = CHINESE_RE.test(rawValue)
? rawValue
: (textOriginals.get(node) ?? rawValue);
if (!CHINESE_RE.test(originalValue)) {
return;
}
const translatedValue = translateText(originalValue);
if (translatedValue !== originalValue) {
textOriginals.set(node, originalValue);
if (rawValue !== translatedValue) {
node.nodeValue = translatedValue;
}
}
return;
}
const originalValue = textOriginals.get(node);
if (originalValue !== undefined && rawValue !== originalValue) {
node.nodeValue = originalValue;
}
}
function processNode(root: Node) {
const stack: Node[] = [root];
while (stack.length > 0) {
const node = stack.pop();
if (!node) {
continue;
}
if (node.nodeType === Node.TEXT_NODE) {
translateTextNode(node as Text);
continue;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}
const element = node as Element;
translateElementAttributes(element);
for (let index = element.childNodes.length - 1; index >= 0; index -= 1) {
const childNode = element.childNodes[index];
if (childNode) {
stack.push(childNode);
}
}
}
}
function refreshRuntimeLocale() {
if (typeof document === 'undefined' || !document.body) {
return;
}
processNode(document.body);
}
function startObserver() {
if (
observer ||
typeof document === 'undefined' ||
typeof MutationObserver === 'undefined' ||
!document.body
) {
return;
}
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' || mutation.type === 'characterData') {
processNode(mutation.target);
continue;
}
mutation.addedNodes.forEach((node) => processNode(node));
}
});
observer.observe(document.body, {
attributeFilter: [...TRANSLATABLE_ATTRIBUTES],
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
}
function setupRuntimeLocaleSync() {
if (initialized) {
return;
}
initialized = true;
watch(
() => getCurrentLocale(),
async () => {
runtimeMatcherCache = undefined;
await nextTick();
refreshRuntimeLocale();
startObserver();
},
{
flush: 'post',
immediate: true,
},
);
}
export { setupRuntimeLocaleSync, translateText as translateLocaleText };

View File

@ -4,11 +4,13 @@ import { useRoute } from 'vue-router';
import { Page } from '@vben/common-ui';
import { $t } from '#/locales';
defineOptions({ name: 'LegacyExternalIframe' });
const route = useRoute();
const title = computed(() => String(route.meta.title || '外部页面'));
const title = computed(() => String($t(String(route.meta.title || '外部页面'))));
const iframeSrc = computed(() => String(route.meta.iframeSrc || ''));
</script>

View File

@ -240,16 +240,16 @@ const getForceMount = computed(() => {
/>
<SheetTitle v-if="title" class="text-left">
<slot name="title">
{{ title }}
{{ $t(title) }}
<VbenHelpTooltip v-if="titleTooltip" trigger-class="pb-1">
{{ titleTooltip }}
{{ $t(titleTooltip) }}
</VbenHelpTooltip>
</slot>
</SheetTitle>
<SheetDescription v-if="description" class="mt-1 text-xs">
<slot name="description">
{{ description }}
{{ $t(description) }}
</slot>
</SheetDescription>
</div>

View File

@ -287,18 +287,18 @@ function handleClosed() {
>
<DialogTitle v-if="title" class="text-left">
<slot name="title">
{{ title }}
{{ $t(title) }}
<slot v-if="titleTooltip" name="titleTooltip">
<VbenHelpTooltip trigger-class="pb-1">
{{ titleTooltip }}
{{ $t(titleTooltip) }}
</VbenHelpTooltip>
</slot>
</slot>
</DialogTitle>
<DialogDescription v-if="description">
<slot name="description">
{{ description }}
{{ $t(description) }}
</slot>
</DialogDescription>
<VisuallyHidden v-if="!title || !description">

View File

@ -5,6 +5,7 @@ import type { PageProps } from './types';
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { $t } from '@vben/locales';
import { CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT } from '@vben-core/shared/constants';
import { cn } from '@vben-core/shared/utils';
@ -70,13 +71,13 @@ onMounted(() => {
<div class="flex-auto">
<slot name="title">
<div v-if="title" class="mb-2 flex text-lg font-semibold">
{{ title }}
{{ $t(title) }}
</div>
</slot>
<slot name="description">
<p v-if="description" class="text-muted-foreground">
{{ description }}
{{ $t(description) }}
</p>
</slot>
</div>

View File

@ -36,7 +36,7 @@ const IconOffline = defineAsyncComponent(
const titleText = computed(() => {
if (props.title) {
return props.title;
return $t(props.title);
}
switch (props.status) {
@ -63,7 +63,7 @@ const titleText = computed(() => {
const descText = computed(() => {
if (props.description) {
return props.description;
return $t(props.description);
}
switch (props.status) {
case '403': {

View File

@ -70,6 +70,10 @@ const headerTheme = computed(() => {
return dark ? 'dark' : 'light';
});
const appName = computed(() => {
return $t(preferences.app.name);
});
const logoClass = computed(() => {
const { collapsedShowTitle } = preferences.sidebar;
const classes: string[] = [];
@ -281,7 +285,7 @@ const headerSlots = computed(() => {
:collapsed="logoCollapsed"
:src="preferences.logo.source"
:src-dark="preferences.logo.sourceDark"
:text="preferences.app.name"
:text="appName"
:theme="showHeaderNav ? headerTheme : theme"
@click="clickLogo"
>
@ -372,7 +376,7 @@ const headerSlots = computed(() => {
<VbenLogo
v-if="preferences.logo.enable"
:fit="preferences.logo.fit"
:text="preferences.app.name"
:text="appName"
:theme="sidebarThemeSub"
>
<template v-if="$slots['logo-text']" #text>