fix: resolve E2E audit findings and add Phase C frontend pages

- Fix audit_log handler multi-tenant bug: use Extension<TenantContext>
  instead of hardcoded default_tenant_id
- Fix sendMessage route mismatch: frontend /messages/send → /messages
- Add POST /users/{id}/roles backend route for role assignment
- Add task.completed event payload: started_by + instance_id for
  notification delivery
- Add audit log viewer frontend page (AuditLogViewer.tsx)
- Add language management frontend page (LanguageManager.tsx)
- Add api/auditLogs.ts and api/languages.ts modules
This commit is contained in:
iven
2026-04-12 15:57:33 +08:00
parent 14f431efff
commit 3b41e73f82
11 changed files with 567 additions and 12 deletions

View File

@@ -0,0 +1,31 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface AuditLogItem {
id: string;
tenant_id: string;
action: string;
resource_type: string;
resource_id: string;
user_id: string;
old_value?: string;
new_value?: string;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface AuditLogQuery {
resource_type?: string;
user_id?: string;
page?: number;
page_size?: number;
}
export async function listAuditLogs(query: AuditLogQuery = {}) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<AuditLogItem> }>(
'/audit-logs',
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
);
return data.data;
}

View File

@@ -0,0 +1,36 @@
import client from './client';
// --- Types ---
export interface LanguageInfo {
code: string;
name: string;
enabled: boolean;
translations?: Record<string, string>;
}
export interface UpdateLanguageRequest {
name?: string;
enabled?: boolean;
translations?: Record<string, string>;
}
// --- API Functions ---
export async function listLanguages(): Promise<LanguageInfo[]> {
const { data } = await client.get<{ success: boolean; data: LanguageInfo[] }>(
'/config/languages',
);
return data.data;
}
export async function updateLanguage(
code: string,
req: UpdateLanguageRequest,
): Promise<LanguageInfo> {
const { data } = await client.put<{ success: boolean; data: LanguageInfo }>(
`/config/languages/${code}`,
req,
);
return data.data;
}

View File

@@ -81,7 +81,7 @@ export async function deleteMessage(id: string) {
export async function sendMessage(req: SendMessageRequest) {
const { data } = await client.post<{ success: boolean; data: MessageInfo }>(
'/messages/send',
'/messages',
req,
);
return data.data;

View File

@@ -1,17 +1,21 @@
import { Tabs } from 'antd';
import DictionaryManager from './settings/DictionaryManager';
import LanguageManager from './settings/LanguageManager';
import MenuConfig from './settings/MenuConfig';
import NumberingRules from './settings/NumberingRules';
import SystemSettings from './settings/SystemSettings';
import ThemeSettings from './settings/ThemeSettings';
import AuditLogViewer from './settings/AuditLogViewer';
const Settings: React.FC = () => {
const items = [
{ key: 'dictionaries', label: '数据字典', children: <DictionaryManager /> },
{ key: 'languages', label: '语言管理', children: <LanguageManager /> },
{ key: 'menus', label: '菜单配置', children: <MenuConfig /> },
{ key: 'numbering', label: '编号规则', children: <NumberingRules /> },
{ key: 'settings', label: '系统参数', children: <SystemSettings /> },
{ key: 'theme', label: '主题设置', children: <ThemeSettings /> },
{ key: 'audit-log', label: '审计日志', children: <AuditLogViewer /> },
];
return <Tabs defaultActiveKey="dictionaries" items={items} />;

View File

@@ -0,0 +1,156 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Select, Input, Space, Card, Typography, Tag, message } from 'antd';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs';
const RESOURCE_TYPE_OPTIONS = [
{ value: 'user', label: '用户' },
{ value: 'role', label: '角色' },
{ value: 'organization', label: '组织' },
{ value: 'department', label: '部门' },
{ value: 'position', label: '岗位' },
{ value: 'process_instance', label: '流程实例' },
{ value: 'dictionary', label: '字典' },
{ value: 'menu', label: '菜单' },
{ value: 'setting', label: '设置' },
{ value: 'numbering_rule', label: '编号规则' },
];
const ACTION_COLOR_MAP: Record<string, string> = {
create: 'green',
update: 'blue',
delete: 'red',
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
export default function AuditLogViewer() {
const [logs, setLogs] = useState<AuditLogItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<AuditLogQuery>({ page: 1, page_size: 20 });
const fetchLogs = useCallback(async (params: AuditLogQuery) => {
setLoading(true);
try {
const result = await listAuditLogs(params);
setLogs(result.data);
setTotal(result.total);
} catch {
message.error('加载审计日志失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchLogs(query);
}, [query, fetchLogs]);
const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => {
setQuery((prev) => ({
...prev,
[field]: value || undefined,
page: 1,
}));
};
const handleTableChange = (pagination: TablePaginationConfig) => {
setQuery((prev) => ({
...prev,
page: pagination.current,
page_size: pagination.pageSize,
}));
};
const columns: ColumnsType<AuditLogItem> = [
{
title: '操作',
dataIndex: 'action',
key: 'action',
width: 120,
render: (action: string) => (
<Tag color={ACTION_COLOR_MAP[action] ?? 'default'}>{action}</Tag>
),
},
{
title: '资源类型',
dataIndex: 'resource_type',
key: 'resource_type',
width: 140,
},
{
title: '资源 ID',
dataIndex: 'resource_id',
key: 'resource_id',
width: 200,
ellipsis: true,
},
{
title: '操作用户',
dataIndex: 'user_id',
key: 'user_id',
width: 200,
ellipsis: true,
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 200,
render: (value: string) => formatDateTime(value),
},
];
return (
<div>
<Typography.Title level={5} style={{ marginBottom: 16 }}>
</Typography.Title>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Select
allowClear
placeholder="资源类型"
style={{ width: 160 }}
options={RESOURCE_TYPE_OPTIONS}
value={query.resource_type}
onChange={(value) => handleFilterChange('resource_type', value)}
/>
<Input
allowClear
placeholder="操作用户 ID"
style={{ width: 240 }}
value={query.user_id ?? ''}
onChange={(e) => handleFilterChange('user_id', e.target.value)}
/>
</Space>
</Card>
<Table
rowKey="id"
columns={columns}
dataSource={logs}
loading={loading}
onChange={handleTableChange}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 900 }}
/>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Switch,
Modal,
Form,
Input,
Button,
Space,
Typography,
message,
Card,
} from 'antd';
import { EditOutlined } from '@ant-design/icons';
import {
listLanguages,
updateLanguage,
type LanguageInfo,
} from '../../api/languages';
// --- Component ---
export default function LanguageManager() {
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingLang, setEditingLang] = useState<LanguageInfo | null>(null);
const [editForm] = Form.useForm();
const fetchLanguages = useCallback(async () => {
setLoading(true);
try {
const result = await listLanguages();
setLanguages(result);
} catch {
message.error('加载语言列表失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchLanguages();
}, [fetchLanguages]);
// --- Enable / Disable Toggle ---
const handleToggle = async (record: LanguageInfo, enabled: boolean) => {
try {
await updateLanguage(record.code, { enabled });
setLanguages((prev) =>
prev.map((lang) =>
lang.code === record.code ? { ...lang, enabled } : lang,
),
);
message.success(enabled ? '已启用' : '已禁用');
} catch {
message.error('操作失败');
}
};
// --- Edit Modal ---
const openEdit = (lang: LanguageInfo) => {
setEditingLang(lang);
editForm.setFieldsValue({
name: lang.name,
translations: lang.translations
? Object.entries(lang.translations)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
: '',
});
setEditModalOpen(true);
};
const closeEdit = () => {
setEditModalOpen(false);
setEditingLang(null);
editForm.resetFields();
};
const handleEditSubmit = async (values: { name: string; translations: string }) => {
if (!editingLang) return;
const translations: Record<string, string> = {};
if (values.translations?.trim()) {
for (const line of values.translations.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
const val = trimmed.slice(eqIndex + 1).trim();
if (key) {
translations[key] = val;
}
}
}
try {
const updated = await updateLanguage(editingLang.code, {
name: values.name,
translations,
});
setLanguages((prev) =>
prev.map((lang) =>
lang.code === editingLang.code ? updated : lang,
),
);
message.success('语言更新成功');
closeEdit();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '更新失败';
message.error(errorMsg);
}
};
// --- Columns ---
const columns = [
{
title: '语言代码',
dataIndex: 'code',
key: 'code',
width: 160,
},
{
title: '语言名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 120,
render: (enabled: boolean, record: LanguageInfo) => (
<Switch checked={enabled} onChange={(checked) => handleToggle(record, checked)} />
),
},
{
title: '翻译条目数',
key: 'translationCount',
width: 140,
render: (_: unknown, record: LanguageInfo) =>
record.translations ? Object.keys(record.translations).length : 0,
},
{
title: '操作',
key: 'actions',
render: (_: unknown, record: LanguageInfo) => (
<Space>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
>
</Button>
</Space>
),
},
];
return (
<div>
<Typography.Title level={5} style={{ marginBottom: 16 }}>
</Typography.Title>
<Card>
<Table
columns={columns}
dataSource={languages}
rowKey="code"
loading={loading}
pagination={false}
/>
</Card>
{/* Edit Modal */}
<Modal
title={`编辑语言 - ${editingLang?.name ?? ''}`}
open={editModalOpen}
onCancel={closeEdit}
onOk={() => editForm.submit()}
>
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical">
<Form.Item
name="name"
label="语言名称"
rules={[{ required: true, message: '请输入语言名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="translations"
label="翻译内容"
extra="每行一条格式key=value"
>
<Input.TextArea rows={10} placeholder={'common.save=保存\ncommon.cancel=取消'} />
</Form.Item>
</Form>
</Modal>
</div>
);
}