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()); }