fix: 低优先级收尾 — 图片上传/语言编辑/插件恢复/URL 编码
- P3-2: ArticleEditor 图片上传接入 /upload 端点 + 封面图上传按钮 - P4-3: recover_plugins 添加 tenant 日志 + 同 ID 去重保护 - P4-4: LanguageManager 编辑弹窗改为真实表单 (name 字段) + 后端 name 持久化 - P4-6: Settings API getSetting/updateSetting 添加 encodeURIComponent
This commit is contained in:
@@ -10,6 +10,7 @@ export interface LanguageInfo {
|
|||||||
|
|
||||||
export interface UpdateLanguageRequest {
|
export interface UpdateLanguageRequest {
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface SettingInfo {
|
|||||||
|
|
||||||
export async function getSetting(key: string, scope?: string, scopeId?: string) {
|
export async function getSetting(key: string, scope?: string, scopeId?: string) {
|
||||||
const { data } = await client.get<{ success: boolean; data: SettingInfo }>(
|
const { data } = await client.get<{ success: boolean; data: SettingInfo }>(
|
||||||
`/config/settings/${key}`,
|
`/config/settings/${encodeURIComponent(key)}`,
|
||||||
{ params: { scope, scope_id: scopeId } },
|
{ params: { scope, scope_id: scopeId } },
|
||||||
);
|
);
|
||||||
return data.data;
|
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) {
|
export async function updateSetting(key: string, settingValue: unknown, version?: number) {
|
||||||
const { data } = await client.put<{ success: boolean; data: SettingInfo }>(
|
const { data } = await client.put<{ success: boolean; data: SettingInfo }>(
|
||||||
`/config/settings/${key}`,
|
`/config/settings/${encodeURIComponent(key)}`,
|
||||||
{ setting_value: settingValue, version },
|
{ setting_value: settingValue, version },
|
||||||
);
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Button, Input, Select, Space, message, Spin } from 'antd';
|
import { Button, Input, Select, Space, message, Spin, Upload } from 'antd';
|
||||||
import { ArrowLeftOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, SaveOutlined, SendOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||||
import {
|
import {
|
||||||
@@ -104,10 +104,24 @@ export default function ArticleEditor() {
|
|||||||
placeholder: '请输入文章内容...',
|
placeholder: '请输入文章内容...',
|
||||||
MENU_CONF: {
|
MENU_CONF: {
|
||||||
uploadImage: {
|
uploadImage: {
|
||||||
// 自定义图片上传 - 预留后端接口
|
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
|
||||||
async customUpload(_file: File, _insertFn: (url: string, alt?: string, href?: string) => void) {
|
try {
|
||||||
// TODO: 实现图片上传到后端
|
const formData = new FormData();
|
||||||
message.warning('图片上传功能待实现');
|
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',
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
封面图 URL
|
封面图
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
value={coverImage}
|
<Input
|
||||||
onChange={(e) => setCoverImage(e.target.value)}
|
value={coverImage}
|
||||||
placeholder="请输入封面图片 URL"
|
onChange={(e) => setCoverImage(e.target.value)}
|
||||||
/>
|
placeholder="请输入封面图片 URL 或上传文件"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Upload
|
||||||
|
accept="image/*"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={async (file) => {
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>上传</Button>
|
||||||
|
</Upload>
|
||||||
|
</Space.Compact>
|
||||||
{coverImage && (
|
{coverImage && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
Card,
|
Card,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { EditOutlined } from '@ant-design/icons';
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
@@ -16,13 +18,12 @@ import {
|
|||||||
type LanguageInfo,
|
type LanguageInfo,
|
||||||
} from '../../api/languages';
|
} from '../../api/languages';
|
||||||
|
|
||||||
// --- Component ---
|
|
||||||
|
|
||||||
export default function LanguageManager() {
|
export default function LanguageManager() {
|
||||||
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
|
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [editingLang, setEditingLang] = useState<LanguageInfo | null>(null);
|
const [editingLang, setEditingLang] = useState<LanguageInfo | null>(null);
|
||||||
|
const [form] = Form.useForm<{ name: string }>();
|
||||||
|
|
||||||
const fetchLanguages = useCallback(async () => {
|
const fetchLanguages = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -39,8 +40,6 @@ export default function LanguageManager() {
|
|||||||
fetchLanguages();
|
fetchLanguages();
|
||||||
}, [fetchLanguages]);
|
}, [fetchLanguages]);
|
||||||
|
|
||||||
// --- Enable / Disable Toggle ---
|
|
||||||
|
|
||||||
const handleToggle = async (record: LanguageInfo, checked: boolean) => {
|
const handleToggle = async (record: LanguageInfo, checked: boolean) => {
|
||||||
try {
|
try {
|
||||||
await updateLanguage(record.code, { is_active: checked });
|
await updateLanguage(record.code, { is_active: checked });
|
||||||
@@ -55,24 +54,25 @@ export default function LanguageManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Edit Modal ---
|
|
||||||
|
|
||||||
const openEdit = (lang: LanguageInfo) => {
|
const openEdit = (lang: LanguageInfo) => {
|
||||||
setEditingLang(lang);
|
setEditingLang(lang);
|
||||||
|
form.setFieldsValue({ name: lang.name });
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEdit = () => {
|
const closeEdit = () => {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setEditingLang(null);
|
setEditingLang(null);
|
||||||
|
form.resetFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSubmit = async () => {
|
const handleEditSubmit = async () => {
|
||||||
if (!editingLang) return;
|
if (!editingLang) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
const updated = await updateLanguage(editingLang.code, {
|
const updated = await updateLanguage(editingLang.code, {
|
||||||
is_active: editingLang.is_active,
|
is_active: editingLang.is_active,
|
||||||
|
name: values.name,
|
||||||
});
|
});
|
||||||
setLanguages((prev) =>
|
setLanguages((prev) =>
|
||||||
prev.map((lang) =>
|
prev.map((lang) =>
|
||||||
@@ -82,6 +82,7 @@ export default function LanguageManager() {
|
|||||||
message.success('语言更新成功');
|
message.success('语言更新成功');
|
||||||
closeEdit();
|
closeEdit();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
?.message || '更新失败';
|
?.message || '更新失败';
|
||||||
@@ -89,8 +90,6 @@ export default function LanguageManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Columns ---
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '语言代码',
|
title: '语言代码',
|
||||||
@@ -113,13 +112,6 @@ export default function LanguageManager() {
|
|||||||
<Switch checked={is_active} onChange={(checked) => handleToggle(record, checked)} />
|
<Switch checked={is_active} onChange={(checked) => handleToggle(record, checked)} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '状态标签',
|
|
||||||
key: 'statusLabel',
|
|
||||||
width: 140,
|
|
||||||
render: (_: unknown, record: LanguageInfo) =>
|
|
||||||
record.is_active ? '已启用' : '已禁用',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -153,22 +145,25 @@ export default function LanguageManager() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`语言详情 - ${editingLang?.name ?? ''}`}
|
title={`编辑语言 - ${editingLang?.code ?? ''}`}
|
||||||
open={editModalOpen}
|
open={editModalOpen}
|
||||||
onCancel={closeEdit}
|
onCancel={closeEdit}
|
||||||
onOk={() => handleEditSubmit()}
|
onOk={handleEditSubmit}
|
||||||
|
okText="保存"
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<strong>语言代码:</strong>{editingLang?.code}
|
<Form.Item label="语言代码">
|
||||||
</div>
|
<Input value={editingLang?.code} disabled />
|
||||||
<div style={{ marginBottom: 12 }}>
|
</Form.Item>
|
||||||
<strong>语言名称:</strong>{editingLang?.name}
|
<Form.Item
|
||||||
</div>
|
label="语言名称"
|
||||||
<div>
|
name="name"
|
||||||
<strong>状态:</strong>{editingLang?.is_active ? '已启用' : '已禁用'}
|
rules={[{ required: true, message: '请输入语言名称' }]}
|
||||||
</div>
|
>
|
||||||
|
<Input placeholder="例如:简体中文" maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ pub struct LanguageResp {
|
|||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
pub struct UpdateLanguageReq {
|
pub struct UpdateLanguageReq {
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ where
|
|||||||
.filter(|s| s.setting_key.starts_with("language."))
|
.filter(|s| s.setting_key.starts_with("language."))
|
||||||
.filter_map(|s| {
|
.filter_map(|s| {
|
||||||
let code = s.setting_key.strip_prefix("language.")?.to_string();
|
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
|
let is_active = s
|
||||||
.setting_value
|
.setting_value
|
||||||
.get("is_active")
|
.get("is_active")
|
||||||
@@ -98,7 +103,10 @@ where
|
|||||||
require_permission(&ctx, "language.update")?;
|
require_permission(&ctx, "language.update")?;
|
||||||
|
|
||||||
let key = format!("language.{}", code);
|
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(
|
SettingService::set(
|
||||||
SetSettingParams {
|
SetSettingParams {
|
||||||
|
|||||||
@@ -455,14 +455,27 @@ impl PluginEngine {
|
|||||||
|
|
||||||
let mut recovered = Vec::new();
|
let mut recovered = Vec::new();
|
||||||
for model in running_plugins {
|
for model in running_plugins {
|
||||||
|
let tenant_id = model.tenant_id;
|
||||||
let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone())
|
let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone())
|
||||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||||
let plugin_id_str = &manifest.metadata.id;
|
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 到内存
|
// 加载 WASM 到内存
|
||||||
if let Err(e) = self.load(plugin_id_str, &model.wasm_binary, manifest.clone()).await {
|
if let Err(e) = self.load(plugin_id_str, &model.wasm_binary, manifest.clone()).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
plugin_id = %plugin_id_str,
|
plugin_id = %plugin_id_str,
|
||||||
|
tenant_id = %tenant_id,
|
||||||
error = %e,
|
error = %e,
|
||||||
"Failed to recover plugin (load)"
|
"Failed to recover plugin (load)"
|
||||||
);
|
);
|
||||||
@@ -473,6 +486,7 @@ impl PluginEngine {
|
|||||||
if let Err(e) = self.initialize(plugin_id_str).await {
|
if let Err(e) = self.initialize(plugin_id_str).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
plugin_id = %plugin_id_str,
|
plugin_id = %plugin_id_str,
|
||||||
|
tenant_id = %tenant_id,
|
||||||
error = %e,
|
error = %e,
|
||||||
"Failed to recover plugin (initialize)"
|
"Failed to recover plugin (initialize)"
|
||||||
);
|
);
|
||||||
@@ -483,13 +497,18 @@ impl PluginEngine {
|
|||||||
if let Err(e) = self.start_event_listener(plugin_id_str).await {
|
if let Err(e) = self.start_event_listener(plugin_id_str).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
plugin_id = %plugin_id_str,
|
plugin_id = %plugin_id_str,
|
||||||
|
tenant_id = %tenant_id,
|
||||||
error = %e,
|
error = %e,
|
||||||
"Failed to recover plugin (start_event_listener)"
|
"Failed to recover plugin (start_event_listener)"
|
||||||
);
|
);
|
||||||
continue;
|
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());
|
recovered.push(plugin_id_str.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user