增加i18n国际化多语言支持

This commit is contained in:
jingrow 2025-09-11 13:50:22 +08:00
parent 69eb39ff44
commit 2edff4a061
4 changed files with 559 additions and 20 deletions

View File

@ -11,11 +11,19 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { zhCN, dateZhCN, NMessageProvider, NDialogProvider, NNotificationProvider } from 'naive-ui'
import type { GlobalTheme } from 'naive-ui'
import { provideLocale, initLocale } from '@/shared/i18n'
const theme = ref<GlobalTheme | null>(null)
//
provideLocale()
onMounted(() => {
initLocale()
})
</script>
<style>

View File

@ -0,0 +1,160 @@
import { ref, provide, inject, InjectionKey } from 'vue'
export interface LocaleConfig {
code: string
name: string
flag: string
}
// 支持的语言列表
export const supportedLocales: LocaleConfig[] = [
{ code: 'zh-CN', name: '简体中文', flag: '🇨🇳' },
{ code: 'en-US', name: 'English', flag: '🇺🇸' }
]
// 翻译内容
export const translations: Record<string, Record<string, string>> = {
'zh-CN': {
// 节点状态
'status.active': '活跃',
'status.inactive': '停用',
'status.draft': '草稿',
'status.pending': '待处理',
'status.completed': '已完成',
'status.failed': '失败',
'status.running': '运行中',
'status.stopped': '已停止',
// 节点分类
'category.input': '输入',
'category.output': '输出',
'category.process': '处理',
'category.condition': '条件',
'category.loop': '循环',
'category.api': 'API',
'category.database': '数据库',
'category.ai': 'AI',
'category.utility': '工具',
// 节点管理页面
'nodeManagement.title': '节点管理',
'nodeManagement.description': '管理和配置您的AI节点组件',
'nodeManagement.cardView': '卡片视图',
'nodeManagement.listView': '列表视图',
'nodeManagement.loading': '加载中...',
'nodeManagement.noData': '暂无数据',
'nodeManagement.viewDetails': '查看详情',
// 表格头部
'table.icon': '图标',
'table.name': '名称',
'table.type': '类型',
'table.category': '分类',
'table.status': '状态',
'table.actions': '操作'
},
'en-US': {
// Node Status
'status.active': 'Active',
'status.inactive': 'Inactive',
'status.draft': 'Draft',
'status.pending': 'Pending',
'status.completed': 'Completed',
'status.failed': 'Failed',
'status.running': 'Running',
'status.stopped': 'Stopped',
// Node Categories
'category.input': 'Input',
'category.output': 'Output',
'category.process': 'Process',
'category.condition': 'Condition',
'category.loop': 'Loop',
'category.api': 'API',
'category.database': 'Database',
'category.ai': 'AI',
'category.utility': 'Utility',
// Node Management Page
'nodeManagement.title': 'Node Management',
'nodeManagement.description': 'Manage and configure your AI node components',
'nodeManagement.cardView': 'Card View',
'nodeManagement.listView': 'List View',
'nodeManagement.loading': 'Loading...',
'nodeManagement.noData': 'No data available',
'nodeManagement.viewDetails': 'View Details',
// Table headers
'table.icon': 'Icon',
'table.name': 'Name',
'table.type': 'Type',
'table.category': 'Category',
'table.status': 'Status',
'table.actions': 'Actions'
}
}
// 语言状态管理
const localeKey: InjectionKey<typeof currentLocale> = Symbol('locale')
// 全局语言状态
const currentLocale = ref<string>('zh-CN')
// 提供语言状态
export function provideLocale() {
provide(localeKey, currentLocale)
return currentLocale
}
// 注入语言状态
export function useLocale() {
const locale = inject(localeKey, currentLocale)
return locale
}
// 获取翻译文本
export function t(key: string, locale?: string): string {
const targetLocale = locale || currentLocale.value
const translation = translations[targetLocale]?.[key]
return translation || key
}
// 设置当前语言
export function setLocale(locale: string) {
if (supportedLocales.some(l => l.code === locale)) {
currentLocale.value = locale
localStorage.setItem('locale', locale)
}
}
// 获取当前语言
export function getCurrentLocale(): string {
return currentLocale.value
}
// 初始化语言
export function initLocale() {
const savedLocale = localStorage.getItem('locale')
if (savedLocale && supportedLocales.some(l => l.code === savedLocale)) {
currentLocale.value = savedLocale
} else {
const browserLanguage = navigator.language
const matchedLocale = supportedLocales.find(l => browserLanguage.startsWith(l.code))
if (matchedLocale) {
currentLocale.value = matchedLocale.code
} else {
currentLocale.value = 'zh-CN'
}
localStorage.setItem('locale', currentLocale.value)
}
}
// 获取状态翻译
export function getStatusTranslation(status: string, locale?: string): string {
return t(`status.${status.toLowerCase()}`, locale)
}
// 获取分类翻译
export function getCategoryTranslation(category: string, locale?: string): string {
return t(`category.${category.toLowerCase()}`, locale)
}

View File

@ -51,6 +51,14 @@
<n-form-item label="超时时间">
<n-input-number v-model:value="systemSettings.timeout" :min="5" :max="300" />
</n-form-item>
<n-form-item label="界面语言">
<n-select
v-model:value="systemSettings.language"
:options="languageOptions"
style="width: 200px"
@update:value="changeLanguage"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" @click="saveSystemSettings">
保存设置
@ -65,8 +73,9 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { NGrid, NGridItem, NCard, NForm, NFormItem, NInput, NButton, NUpload, NInputNumber, useMessage } from 'naive-ui'
import { NGrid, NGridItem, NCard, NForm, NFormItem, NInput, NButton, NUpload, NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useAuthStore } from '@/shared/stores/auth'
import { getCurrentLocale, setLocale, supportedLocales, initLocale } from '@/shared/i18n'
const message = useMessage()
const authStore = useAuthStore()
@ -81,15 +90,27 @@ const systemSettings = reactive({
jingrowApiUrl: (import.meta as any).env.VITE_BACKEND_SERVER_URL || '',
apiKey: (import.meta as any).env.VITE_BACKEND_API_KEY || '',
apiSecret: (import.meta as any).env.VITE_BACKEND_API_SECRET || '',
timeout: 30
timeout: 30,
language: getCurrentLocale()
})
const fileList = ref([])
//
const languageOptions = supportedLocales.map(locale => ({
label: `${locale.flag} ${locale.name}`,
value: locale.code
}))
const handleUploadChange = (options: any) => {
fileList.value = options.fileList
}
const changeLanguage = (locale: string) => {
setLocale(locale)
message.success('语言设置已更新')
}
const saveUserSettings = () => {
message.success('个人设置已保存')
}
@ -99,6 +120,8 @@ const saveSystemSettings = () => {
}
onMounted(() => {
initLocale()
systemSettings.language = getCurrentLocale()
if (authStore.user) {
userSettings.username = authStore.user.username
userSettings.email = authStore.user.email

View File

@ -1,8 +1,29 @@
<template>
<div class="page">
<div class="page-header">
<h2>节点管理</h2>
<div class="actions">
<div class="header-left">
<h2>{{ translate('nodeManagement.title') }}</h2>
<p class="page-description">{{ translate('nodeManagement.description') }}</p>
</div>
<div class="header-right">
<div class="view-toggle">
<button
class="toggle-btn"
:class="{ active: viewMode === 'card' }"
@click="viewMode = 'card'"
:title="translate('nodeManagement.cardView')"
>
<i class="fa fa-th-large"></i>
</button>
<button
class="toggle-btn"
:class="{ active: viewMode === 'list' }"
@click="viewMode = 'list'"
:title="translate('nodeManagement.listView')"
>
<i class="fa fa-list"></i>
</button>
</div>
<button class="refresh-btn" @click="reload" :disabled="loading">
<i :class="loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"></i>
</button>
@ -11,10 +32,11 @@
<div class="page-content">
<div v-if="loading" class="loading">
<i class="fa fa-spinner fa-spin"></i> 加载中...
<i class="fa fa-spinner fa-spin"></i> {{ translate('nodeManagement.loading') }}
</div>
<div v-else>
<div class="node-grid">
<!-- 卡片视图 -->
<div v-if="viewMode === 'card'" class="node-grid">
<div v-for="node in nodes" :key="node.name" class="node-card" @click="openDetail(node.name)">
<div class="icon" :style="{ color: node.node_color || '#6b7280' }">
<i :class="resolveIcon(node.node_icon)"></i>
@ -24,6 +46,44 @@
<div class="desc">{{ node.description || '—' }}</div>
<div class="meta">
<span v-if="node.category" class="badge">{{ node.category }}</span>
<span v-if="node.status" class="status-badge" :class="node.status">{{ node.status }}</span>
</div>
</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="node-list">
<div class="list-header">
<div class="col-icon">{{ translate('table.icon') }}</div>
<div class="col-name">{{ translate('table.name') }}</div>
<div class="col-type">{{ translate('table.type') }}</div>
<div class="col-category">{{ translate('table.category') }}</div>
<div class="col-status">{{ translate('table.status') }}</div>
<div class="col-actions">{{ translate('table.actions') }}</div>
</div>
<div class="list-body">
<div v-for="node in nodes" :key="node.name" class="node-list-item" @click="openDetail(node.name)">
<div class="col-icon">
<div class="icon" :style="{ color: node.node_color || '#6b7280' }">
<i :class="resolveIcon(node.node_icon)"></i>
</div>
</div>
<div class="col-name">
<div class="name">{{ node.title || node.type }}</div>
<div class="description">{{ node.description || '—' }}</div>
</div>
<div class="col-type">{{ node.type }}</div>
<div class="col-category">
<span v-if="node.category" class="badge">{{ node.category }}</span>
</div>
<div class="col-status">
<span v-if="node.status" class="status-badge" :class="node.status">{{ node.status }}</span>
</div>
<div class="col-actions" @click.stop>
<button class="action-btn" @click="openDetail(node.name)" :title="translate('nodeManagement.viewDetails')">
<i class="fa fa-eye"></i>
</button>
</div>
</div>
</div>
@ -34,12 +94,20 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { t, useLocale } from '@/shared/i18n'
const router = useRouter()
const loading = ref(true)
const nodes = ref<any[]>([])
const viewMode = ref<'card' | 'list'>('card')
// 使
const locale = useLocale()
//
const translate = computed(() => (key: string) => t(key, locale.value))
async function fetchNodes() {
loading.value = true
@ -80,12 +148,81 @@ function openDetail(name: string) {
router.push({ name: 'NodeDetail', params: { name } })
}
onMounted(fetchNodes)
onMounted(() => {
fetchNodes()
})
</script>
<style scoped>
.page { padding: 16px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.page {
padding: 16px;
width: 100%;
min-height: 100vh;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
}
.header-left h2 {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px 0;
}
.page-description {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.view-toggle {
display: flex;
background: #f8fafc;
border-radius: 8px;
padding: 2px;
border: 1px solid #e2e8f0;
}
.toggle-btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
border-radius: 6px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-btn:hover {
background: #e2e8f0;
color: #475569;
}
.toggle-btn.active {
background: #3b82f6;
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.toggle-btn.active:hover {
background: #2563eb;
}
/* 现代化刷新按钮 */
.refresh-btn {
@ -157,14 +294,225 @@ onMounted(fetchNodes)
opacity: 0;
}
}
.node-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.node-card { display: flex; gap: 12px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer; background: #fff; }
.node-card:hover { border-color: #cbd5e1; background: #fafafa; }
.icon { width: 42px; height: 42px; border-radius: 8px; display:flex; align-items:center; justify-content:center; background: #f3f4f6; font-size: 20px; }
.info { flex: 1; }
.title { font-weight: 600; color: #111827; margin-bottom: 2px; }
.desc { color: #6b7280; font-size: 12px; line-height: 1.4; height: 32px; overflow: hidden; }
.meta { margin-top: 6px; display:flex; gap: 6px; }
.badge { background: #eef2ff; color:#334155; padding: 2px 6px; border-radius: 6px; font-size: 11px; }
/* 卡片视图样式 */
.node-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.node-card {
display: flex;
gap: 12px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
cursor: pointer;
background: #fff;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.node-card:hover {
border-color: #3b82f6;
background: #f8fafc;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
font-size: 20px;
flex-shrink: 0;
}
.info {
flex: 1;
min-width: 0;
}
.title {
font-weight: 600;
color: #111827;
margin-bottom: 4px;
font-size: 16px;
}
.desc {
color: #6b7280;
font-size: 13px;
line-height: 1.4;
height: 36px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta {
margin-top: 8px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.badge {
background: #eef2ff;
color: #334155;
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
}
.status-badge {
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
}
.status-badge.active {
background: #dcfce7;
color: #166534;
}
.status-badge.inactive {
background: #fee2e2;
color: #991b1b;
}
.status-badge.draft {
background: #fef3c7;
color: #92400e;
}
/* 列表视图样式 */
.node-list {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
width: 100%;
}
.list-header {
display: grid;
grid-template-columns: 60px 2fr 1fr 1fr 1fr 80px;
gap: 16px;
padding: 16px 20px;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
color: #374151;
font-size: 14px;
}
.list-body {
max-height: 600px;
overflow-y: auto;
}
.node-list-item {
display: grid;
grid-template-columns: 60px 2fr 1fr 1fr 1fr 80px;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.node-list-item:hover {
background: #f8fafc;
}
.node-list-item:last-child {
border-bottom: none;
}
.col-icon {
display: flex;
align-items: center;
}
.col-icon .icon {
width: 36px;
height: 36px;
font-size: 16px;
}
.col-name {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.col-name .name {
font-weight: 600;
color: #111827;
font-size: 14px;
margin-bottom: 2px;
}
.col-name .description {
color: #6b7280;
font-size: 12px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-type {
display: flex;
align-items: center;
color: #6b7280;
font-size: 13px;
}
.col-category {
display: flex;
align-items: center;
}
.col-status {
display: flex;
align-items: center;
}
.col-actions {
display: flex;
align-items: center;
justify-content: center;
}
.action-btn {
width: 28px;
height: 28px;
border: none;
background: #f3f4f6;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.action-btn:hover {
background: #3b82f6;
color: white;
}
</style>