diff --git a/apps/web/src/api/languages.ts b/apps/web/src/api/languages.ts
index c738aa5..ba61fdf 100644
--- a/apps/web/src/api/languages.ts
+++ b/apps/web/src/api/languages.ts
@@ -10,6 +10,7 @@ export interface LanguageInfo {
export interface UpdateLanguageRequest {
is_active: boolean;
+ name?: string;
}
// --- API Functions ---
diff --git a/apps/web/src/api/settings.ts b/apps/web/src/api/settings.ts
index e08ed7f..82539bc 100644
--- a/apps/web/src/api/settings.ts
+++ b/apps/web/src/api/settings.ts
@@ -11,7 +11,7 @@ export interface SettingInfo {
export async function getSetting(key: string, scope?: string, scopeId?: string) {
const { data } = await client.get<{ success: boolean; data: SettingInfo }>(
- `/config/settings/${key}`,
+ `/config/settings/${encodeURIComponent(key)}`,
{ params: { scope, scope_id: scopeId } },
);
return data.data;
@@ -19,7 +19,7 @@ export async function getSetting(key: string, scope?: string, scopeId?: string)
export async function updateSetting(key: string, settingValue: unknown, version?: number) {
const { data } = await client.put<{ success: boolean; data: SettingInfo }>(
- `/config/settings/${key}`,
+ `/config/settings/${encodeURIComponent(key)}`,
{ setting_value: settingValue, version },
);
return data.data;
diff --git a/apps/web/src/pages/health/ArticleEditor.tsx b/apps/web/src/pages/health/ArticleEditor.tsx
index 3a82de7..1a64be3 100644
--- a/apps/web/src/pages/health/ArticleEditor.tsx
+++ b/apps/web/src/pages/health/ArticleEditor.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import { Button, Input, Select, Space, message, Spin } from 'antd';
-import { ArrowLeftOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons';
+import { Button, Input, Select, Space, message, Spin, Upload } from 'antd';
+import { ArrowLeftOutlined, SaveOutlined, SendOutlined, UploadOutlined } from '@ant-design/icons';
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import {
@@ -104,10 +104,24 @@ export default function ArticleEditor() {
placeholder: '请输入文章内容...',
MENU_CONF: {
uploadImage: {
- // 自定义图片上传 - 预留后端接口
- async customUpload(_file: File, _insertFn: (url: string, alt?: string, href?: string) => void) {
- // TODO: 实现图片上传到后端
- message.warning('图片上传功能待实现');
+ async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ const token = localStorage.getItem('access_token');
+ const resp = await fetch('/api/v1/upload', {
+ method: 'POST',
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ body: formData,
+ });
+ if (!resp.ok) throw new Error('上传失败');
+ const result = await resp.json();
+ const url: string = result.data.url;
+ const urlWithToken = token ? `${url}?token=${token}` : url;
+ insertFn(urlWithToken, file.name, urlWithToken);
+ } catch {
+ message.error('图片上传失败');
+ }
},
},
},
@@ -441,13 +455,41 @@ export default function ArticleEditor() {
color: isDark ? '#94a3b8' : '#475569',
}}
>
- 封面图 URL
+ 封面图
- setCoverImage(e.target.value)}
- placeholder="请输入封面图片 URL"
- />
+
+ setCoverImage(e.target.value)}
+ placeholder="请输入封面图片 URL 或上传文件"
+ style={{ flex: 1 }}
+ />
+ {
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ const token = localStorage.getItem('access_token');
+ const resp = await fetch('/api/v1/upload', {
+ method: 'POST',
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ body: formData,
+ });
+ if (!resp.ok) throw new Error('上传失败');
+ const result = await resp.json();
+ setCoverImage(result.data.url);
+ message.success('封面图上传成功');
+ } catch {
+ message.error('封面图上传失败');
+ }
+ return false;
+ }}
+ >
+ }>上传
+
+
{coverImage && (
([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingLang, setEditingLang] = useState
(null);
+ const [form] = Form.useForm<{ name: string }>();
const fetchLanguages = useCallback(async () => {
setLoading(true);
@@ -39,8 +40,6 @@ export default function LanguageManager() {
fetchLanguages();
}, [fetchLanguages]);
- // --- Enable / Disable Toggle ---
-
const handleToggle = async (record: LanguageInfo, checked: boolean) => {
try {
await updateLanguage(record.code, { is_active: checked });
@@ -55,24 +54,25 @@ export default function LanguageManager() {
}
};
- // --- Edit Modal ---
-
const openEdit = (lang: LanguageInfo) => {
setEditingLang(lang);
+ form.setFieldsValue({ name: lang.name });
setEditModalOpen(true);
};
const closeEdit = () => {
setEditModalOpen(false);
setEditingLang(null);
+ form.resetFields();
};
const handleEditSubmit = async () => {
if (!editingLang) return;
-
try {
+ const values = await form.validateFields();
const updated = await updateLanguage(editingLang.code, {
is_active: editingLang.is_active,
+ name: values.name,
});
setLanguages((prev) =>
prev.map((lang) =>
@@ -82,6 +82,7 @@ export default function LanguageManager() {
message.success('语言更新成功');
closeEdit();
} catch (err: unknown) {
+ if (err && typeof err === 'object' && 'errorFields' in err) return;
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '更新失败';
@@ -89,8 +90,6 @@ export default function LanguageManager() {
}
};
- // --- Columns ---
-
const columns = [
{
title: '语言代码',
@@ -113,13 +112,6 @@ export default function LanguageManager() {
handleToggle(record, checked)} />
),
},
- {
- title: '状态标签',
- key: 'statusLabel',
- width: 140,
- render: (_: unknown, record: LanguageInfo) =>
- record.is_active ? '已启用' : '已禁用',
- },
{
title: '操作',
key: 'actions',
@@ -153,22 +145,25 @@ export default function LanguageManager() {
/>
- {/* Edit Modal */}
handleEditSubmit()}
+ onOk={handleEditSubmit}
+ okText="保存"
>
-
- 语言代码:{editingLang?.code}
-
-
- 语言名称:{editingLang?.name}
-
-
- 状态:{editingLang?.is_active ? '已启用' : '已禁用'}
-
+
+
+
+
+
+
+
);
diff --git a/crates/erp-config/src/dto.rs b/crates/erp-config/src/dto.rs
index d4e3bb5..14fd801 100644
--- a/crates/erp-config/src/dto.rs
+++ b/crates/erp-config/src/dto.rs
@@ -238,6 +238,7 @@ pub struct LanguageResp {
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateLanguageReq {
pub is_active: bool,
+ pub name: Option,
}
#[cfg(test)]
diff --git a/crates/erp-config/src/handler/language_handler.rs b/crates/erp-config/src/handler/language_handler.rs
index 37085bb..109ff51 100644
--- a/crates/erp-config/src/handler/language_handler.rs
+++ b/crates/erp-config/src/handler/language_handler.rs
@@ -50,7 +50,12 @@ where
.filter(|s| s.setting_key.starts_with("language."))
.filter_map(|s| {
let code = s.setting_key.strip_prefix("language.")?.to_string();
- let name = code.clone(); // 默认使用 code 作为名称
+ let name = s
+ .setting_value
+ .get("name")
+ .and_then(|v| v.as_str())
+ .unwrap_or(&code)
+ .to_string();
let is_active = s
.setting_value
.get("is_active")
@@ -98,7 +103,10 @@ where
require_permission(&ctx, "language.update")?;
let key = format!("language.{}", code);
- let value = serde_json::json!({"is_active": req.is_active});
+ let mut value = serde_json::json!({"is_active": req.is_active});
+ if let Some(ref name) = req.name {
+ value["name"] = serde_json::Value::String(name.clone());
+ }
SettingService::set(
SetSettingParams {
diff --git a/crates/erp-plugin/src/engine.rs b/crates/erp-plugin/src/engine.rs
index ee1b65a..d0da9a8 100644
--- a/crates/erp-plugin/src/engine.rs
+++ b/crates/erp-plugin/src/engine.rs
@@ -455,14 +455,27 @@ impl PluginEngine {
let mut recovered = Vec::new();
for model in running_plugins {
+ let tenant_id = model.tenant_id;
let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone())
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
let plugin_id_str = &manifest.metadata.id;
+ // 跳过已被其他租户加载的同 ID 插件(WASM 二进制相同,数据隔离在 DB 层)
+ if self.plugins.contains_key(plugin_id_str) {
+ tracing::info!(
+ plugin_id = %plugin_id_str,
+ tenant_id = %tenant_id,
+ "Plugin already loaded by another tenant, skipping duplicate load"
+ );
+ recovered.push(plugin_id_str.clone());
+ continue;
+ }
+
// 加载 WASM 到内存
if let Err(e) = self.load(plugin_id_str, &model.wasm_binary, manifest.clone()).await {
tracing::error!(
plugin_id = %plugin_id_str,
+ tenant_id = %tenant_id,
error = %e,
"Failed to recover plugin (load)"
);
@@ -473,6 +486,7 @@ impl PluginEngine {
if let Err(e) = self.initialize(plugin_id_str).await {
tracing::error!(
plugin_id = %plugin_id_str,
+ tenant_id = %tenant_id,
error = %e,
"Failed to recover plugin (initialize)"
);
@@ -483,13 +497,18 @@ impl PluginEngine {
if let Err(e) = self.start_event_listener(plugin_id_str).await {
tracing::error!(
plugin_id = %plugin_id_str,
+ tenant_id = %tenant_id,
error = %e,
"Failed to recover plugin (start_event_listener)"
);
continue;
}
- tracing::info!(plugin_id = %plugin_id_str, "Plugin recovered");
+ tracing::info!(
+ plugin_id = %plugin_id_str,
+ tenant_id = %tenant_id,
+ "Plugin recovered"
+ );
recovered.push(plugin_id_str.clone());
}