feat: 修改页面
parent
364e49b3df
commit
eaed89c9ec
|
|
@ -6,10 +6,13 @@ import com.imeeting.common.RedisKeys;
|
||||||
import com.imeeting.dto.biz.MeetingDTO;
|
import com.imeeting.dto.biz.MeetingDTO;
|
||||||
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
||||||
import com.imeeting.dto.biz.MeetingVO;
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
|
import com.imeeting.entity.biz.AiTask;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.security.LoginUser;
|
import com.imeeting.security.LoginUser;
|
||||||
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import org.apache.fontbox.ttf.TrueTypeCollection;
|
import org.apache.fontbox.ttf.TrueTypeCollection;
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
import org.apache.pdfbox.pdmodel.PDPage;
|
import org.apache.pdfbox.pdmodel.PDPage;
|
||||||
|
|
@ -42,6 +45,9 @@ import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -55,15 +61,18 @@ import java.util.regex.Pattern;
|
||||||
public class MeetingController {
|
public class MeetingController {
|
||||||
|
|
||||||
private final MeetingService meetingService;
|
private final MeetingService meetingService;
|
||||||
|
private final AiTaskService aiTaskService;
|
||||||
private final PromptTemplateService promptTemplateService;
|
private final PromptTemplateService promptTemplateService;
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final String uploadPath;
|
private final String uploadPath;
|
||||||
|
|
||||||
public MeetingController(MeetingService meetingService,
|
public MeetingController(MeetingService meetingService,
|
||||||
|
AiTaskService aiTaskService,
|
||||||
PromptTemplateService promptTemplateService,
|
PromptTemplateService promptTemplateService,
|
||||||
StringRedisTemplate redisTemplate,
|
StringRedisTemplate redisTemplate,
|
||||||
@Value("${app.upload-path}") String uploadPath) {
|
@Value("${app.upload-path}") String uploadPath) {
|
||||||
this.meetingService = meetingService;
|
this.meetingService = meetingService;
|
||||||
|
this.aiTaskService = aiTaskService;
|
||||||
this.promptTemplateService = promptTemplateService;
|
this.promptTemplateService = promptTemplateService;
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
this.uploadPath = uploadPath;
|
this.uploadPath = uploadPath;
|
||||||
|
|
@ -161,14 +170,31 @@ public class MeetingController {
|
||||||
@GetMapping("/{id}/summary/export")
|
@GetMapping("/{id}/summary/export")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ResponseEntity<byte[]> exportSummary(@PathVariable Long id, @RequestParam(defaultValue = "pdf") String format) {
|
public ResponseEntity<byte[]> exportSummary(@PathVariable Long id, @RequestParam(defaultValue = "pdf") String format) {
|
||||||
|
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
|
Meeting meetingEntity = meetingService.getById(id);
|
||||||
|
if (meetingEntity == null) {
|
||||||
|
throw new RuntimeException("数据未找到,请刷新后重试");
|
||||||
|
}
|
||||||
|
if (!canAccessMeeting(meetingEntity, loginUser)) {
|
||||||
|
throw new RuntimeException("无权下载此会议总结");
|
||||||
|
}
|
||||||
|
|
||||||
MeetingVO meeting = meetingService.getDetail(id);
|
MeetingVO meeting = meetingService.getDetail(id);
|
||||||
if (meeting == null) {
|
if (meeting == null) {
|
||||||
throw new RuntimeException("数据未找到,请刷新后重试");
|
throw new RuntimeException("数据未找到,请刷新后重试");
|
||||||
}
|
}
|
||||||
if (meeting.getSummaryContent() == null || meeting.getSummaryContent().trim().isEmpty()) {
|
|
||||||
|
AiTask latestSummaryTask = findLatestSummaryTask(meetingEntity);
|
||||||
|
if (latestSummaryTask == null || latestSummaryTask.getResultFilePath() == null || latestSummaryTask.getResultFilePath().isBlank()) {
|
||||||
throw new RuntimeException(" AI总结为空");
|
throw new RuntimeException(" AI总结为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||||
|
Path summarySourcePath = Paths.get(basePath, latestSummaryTask.getResultFilePath().replace("\\", "/"));
|
||||||
|
if (!Files.exists(summarySourcePath)) {
|
||||||
|
throw new RuntimeException("总结源文件不存在,请重新总结后再试");
|
||||||
|
}
|
||||||
|
|
||||||
String safeTitle = (meeting.getTitle() == null || meeting.getTitle().trim().isEmpty())
|
String safeTitle = (meeting.getTitle() == null || meeting.getTitle().trim().isEmpty())
|
||||||
? "meeting-summary-" + id
|
? "meeting-summary-" + id
|
||||||
: meeting.getTitle().replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_");
|
: meeting.getTitle().replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_");
|
||||||
|
|
@ -177,18 +203,32 @@ public class MeetingController {
|
||||||
byte[] bytes;
|
byte[] bytes;
|
||||||
String ext;
|
String ext;
|
||||||
String contentType;
|
String contentType;
|
||||||
|
Path exportDir = Paths.get(basePath, "meetings", String.valueOf(id), "exports");
|
||||||
|
Files.createDirectories(exportDir);
|
||||||
|
|
||||||
if ("word".equalsIgnoreCase(format) || "docx".equalsIgnoreCase(format)) {
|
if ("word".equalsIgnoreCase(format) || "docx".equalsIgnoreCase(format)) {
|
||||||
bytes = buildWordBytes(meeting);
|
|
||||||
ext = "docx";
|
ext = "docx";
|
||||||
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
} else if ("pdf".equalsIgnoreCase(format)) {
|
} else if ("pdf".equalsIgnoreCase(format)) {
|
||||||
bytes = buildPdfBytes(meeting);
|
|
||||||
ext = "pdf";
|
ext = "pdf";
|
||||||
contentType = MediaType.APPLICATION_PDF_VALUE;
|
contentType = MediaType.APPLICATION_PDF_VALUE;
|
||||||
} else {
|
} else {
|
||||||
throw new RuntimeException("格式化失败");
|
throw new RuntimeException("格式化失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Path exportPath = exportDir.resolve(latestSummaryTask.getId() + "." + ext);
|
||||||
|
boolean needRegenerate = !Files.exists(exportPath) ||
|
||||||
|
Files.getLastModifiedTime(exportPath).toMillis() < Files.getLastModifiedTime(summarySourcePath).toMillis();
|
||||||
|
|
||||||
|
if (needRegenerate) {
|
||||||
|
String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8);
|
||||||
|
meeting.setSummaryContent(stripFrontMatter(markdown));
|
||||||
|
bytes = "docx".equals(ext) ? buildWordBytes(meeting) : buildPdfBytes(meeting);
|
||||||
|
Files.write(exportPath, bytes);
|
||||||
|
} else {
|
||||||
|
bytes = Files.readAllBytes(exportPath);
|
||||||
|
}
|
||||||
|
|
||||||
String filename = safeTitle + "-AI-总结." + ext;
|
String filename = safeTitle + "-AI-总结." + ext;
|
||||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
|
|
@ -200,6 +240,56 @@ public class MeetingController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AiTask findLatestSummaryTask(Meeting meeting) {
|
||||||
|
if (meeting.getLatestSummaryTaskId() != null) {
|
||||||
|
AiTask task = aiTaskService.getById(meeting.getLatestSummaryTaskId());
|
||||||
|
if (task != null && "SUMMARY".equals(task.getTaskType()) && Integer.valueOf(2).equals(task.getStatus())
|
||||||
|
&& task.getResultFilePath() != null && !task.getResultFilePath().isBlank()) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meeting.getId())
|
||||||
|
.eq(AiTask::getTaskType, "SUMMARY")
|
||||||
|
.eq(AiTask::getStatus, 2)
|
||||||
|
.isNotNull(AiTask::getResultFilePath)
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean canAccessMeeting(Meeting meeting, LoginUser user) {
|
||||||
|
if (Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (meeting.getCreatorId() != null && meeting.getCreatorId().equals(user.getUserId())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (meeting.getParticipants() == null || meeting.getParticipants().isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String target = "," + user.getUserId() + ",";
|
||||||
|
return ("," + meeting.getParticipants() + ",").contains(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stripFrontMatter(String markdown) {
|
||||||
|
if (markdown == null || markdown.isBlank()) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
if (!markdown.startsWith("---")) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
int second = markdown.indexOf("\n---", 3);
|
||||||
|
if (second < 0) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
int contentStart = second + 4;
|
||||||
|
if (contentStart < markdown.length() && markdown.charAt(contentStart) == '\n') {
|
||||||
|
contentStart++;
|
||||||
|
}
|
||||||
|
return markdown.substring(contentStart).trim();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/transcripts/{id}")
|
@GetMapping("/transcripts/{id}")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) {
|
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) {
|
||||||
|
|
|
||||||
|
|
@ -355,9 +355,26 @@ export default function AppLayout() {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
{(platformConfig?.icpInfo || platformConfig?.copyrightInfo) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTop: '1px solid #f0f0f0',
|
||||||
|
color: '#8c8c8c',
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 1.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{platformConfig?.icpInfo && <span style={{ margin: '0 12px', whiteSpace: 'nowrap' }}>{platformConfig.icpInfo}</span>}
|
||||||
|
{platformConfig?.copyrightInfo && <span style={{ margin: '0 12px', whiteSpace: 'nowrap' }}>{platformConfig.copyrightInfo}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,174 +1,241 @@
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Col,
|
||||||
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
message,
|
Row,
|
||||||
Space,
|
|
||||||
Typography,
|
Typography,
|
||||||
Upload,
|
Upload,
|
||||||
Row,
|
message,
|
||||||
Col,
|
|
||||||
Divider
|
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getAdminPlatformConfig, updatePlatformConfig, uploadPlatformAsset } from "../api";
|
|
||||||
import {
|
import {
|
||||||
UploadOutlined,
|
FileTextOutlined,
|
||||||
SaveOutlined,
|
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
FileTextOutlined
|
SaveOutlined,
|
||||||
|
UploadOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
import { getAdminPlatformConfig, updatePlatformConfig, uploadPlatformAsset } from "../api";
|
||||||
import type { SysPlatformConfig } from "../types";
|
import type { SysPlatformConfig } from "../types";
|
||||||
import PageHeader from "../components/shared/PageHeader";
|
import PageHeader from "../components/shared/PageHeader";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const cardStyle = {
|
||||||
|
boxShadow: "0 1px 2px rgba(0,0,0,0.08), 0 1px 6px -1px rgba(0,0,0,0.05), 0 2px 4px rgba(0,0,0,0.05)",
|
||||||
|
};
|
||||||
|
|
||||||
export default function PlatformSettings() {
|
export default function PlatformSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm<SysPlatformConfig>();
|
||||||
|
|
||||||
|
const logoUrl = Form.useWatch("logoUrl", form);
|
||||||
|
const iconUrl = Form.useWatch("iconUrl", form);
|
||||||
|
const loginBgUrl = Form.useWatch("loginBgUrl", form);
|
||||||
|
const projectName = Form.useWatch("projectName", form);
|
||||||
|
const icpInfo = Form.useWatch("icpInfo", form);
|
||||||
|
const copyrightInfo = Form.useWatch("copyrightInfo", form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getAdminPlatformConfig();
|
const data = await getAdminPlatformConfig();
|
||||||
form.setFieldsValue(data);
|
form.setFieldsValue(data);
|
||||||
} catch (e) {
|
|
||||||
// Handled by interceptor
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
void loadConfig();
|
||||||
loadConfig();
|
}, [form]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => {
|
const handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => {
|
||||||
try {
|
try {
|
||||||
const url = await uploadPlatformAsset(file);
|
const url = await uploadPlatformAsset(file);
|
||||||
form.setFieldValue(fieldName, url);
|
form.setFieldValue(fieldName, url);
|
||||||
message.success(t('common.success'));
|
message.success(t("common.success"));
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Handled by interceptor
|
// handled by interceptor
|
||||||
}
|
}
|
||||||
return false; // 阻止自动上传
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFinish = async (values: SysPlatformConfig) => {
|
const onFinish = async (values: SysPlatformConfig) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await updatePlatformConfig(values);
|
await updatePlatformConfig(values);
|
||||||
message.success(t('common.success'));
|
sessionStorage.setItem("platformConfig", JSON.stringify(values));
|
||||||
} catch (e) {
|
message.success(t("common.success"));
|
||||||
// Handled by interceptor
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImagePreview = ({ url, label }: { url?: string; label: string }) => (
|
const footerPreview = useMemo(() => {
|
||||||
<div className="flex flex-col items-center justify-center p-4 border border-dashed border-gray-300 rounded-lg bg-gray-50 h-32">
|
return [icpInfo, copyrightInfo].filter(Boolean).join(" | ");
|
||||||
|
}, [copyrightInfo, icpInfo]);
|
||||||
|
|
||||||
|
const renderImagePreview = (url?: string, label?: string) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 96,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
border: "1px dashed #d9d9d9",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fafafa",
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{url ? (
|
{url ? (
|
||||||
<img src={url} alt={label} className="max-h-full max-w-full object-contain" />
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={label}
|
||||||
|
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-gray-400">
|
<div style={{ textAlign: "center", color: "#bfbfbf" }}>
|
||||||
<PictureOutlined style={{ fontSize: 24 }} />
|
<PictureOutlined style={{ fontSize: 24 }} />
|
||||||
<div className="text-xs mt-1">{t('platformSettings.uploadHint')}</div>
|
<div style={{ marginTop: 4, fontSize: 12 }}>{t("platformSettings.uploadHint")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('platformSettings.title')}
|
title={t("platformSettings.title")}
|
||||||
subtitle={t('platformSettings.subtitle')}
|
subtitle={t("platformSettings.subtitle")}
|
||||||
extra={(
|
extra={
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined />}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
onClick={() => form.submit()}
|
onClick={() => form.submit()}
|
||||||
>
|
>
|
||||||
{t('common.save')}
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
overflowX: "hidden",
|
||||||
|
padding: "0 4px 8px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
initialValues={{ projectName: 'iMeeting' }}
|
initialValues={{ projectName: "iMeeting" }}
|
||||||
|
style={{ maxWidth: 1280, margin: "0 auto", paddingBottom: 8 }}
|
||||||
>
|
>
|
||||||
<Row gutter={24}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card
|
<Card
|
||||||
title={<><GlobalOutlined className="mr-2" /> 基础信息</>}
|
loading={loading}
|
||||||
className="shadow-sm mb-6"
|
style={cardStyle}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<GlobalOutlined style={{ marginRight: 8 }} />
|
||||||
|
基础信息
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('platformSettings.projectName')}
|
label={t("platformSettings.projectName")}
|
||||||
name="projectName"
|
name="projectName"
|
||||||
rules={[{ required: true, message: t('platformSettings.projectName') }]}
|
rules={[{ required: true, message: `请输入${t("platformSettings.projectName")}` }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="例如:iMeeting 智能会议系统" />
|
<Input placeholder="例如:iMeeting 智能会议系统" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('platformSettings.desc')} name="systemDescription">
|
<Form.Item label={t("platformSettings.desc")} name="systemDescription" style={{ marginBottom: 0 }}>
|
||||||
<Input.TextArea rows={3} placeholder="系统的简要介绍..." />
|
<Input.TextArea rows={3} placeholder="填写系统简介、定位或品牌说明" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card
|
<Card
|
||||||
title={<><PictureOutlined className="mr-2" /> 视觉资源</>}
|
loading={loading}
|
||||||
className="shadow-sm mb-6"
|
style={cardStyle}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<PictureOutlined style={{ marginRight: 8 }} />
|
||||||
|
视觉资源
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Row gutter={24}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col span={8}>
|
<Col xs={24} md={8}>
|
||||||
<Form.Item label={t('platformSettings.logo')} name="logoUrl">
|
<Form.Item label={t("platformSettings.logo")} name="logoUrl">
|
||||||
<Input placeholder="Logo URL" className="mb-2" />
|
<Input placeholder="Logo URL" style={{ marginBottom: 8 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<ImagePreview url={Form.useWatch('logoUrl', form)} label="Logo" />
|
{renderImagePreview(logoUrl, "Logo")}
|
||||||
<Upload
|
<Upload
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
beforeUpload={(file) => handleUpload(file, 'logoUrl')}
|
beforeUpload={(file) => handleUpload(file, "logoUrl")}
|
||||||
>
|
>
|
||||||
<Button icon={<UploadOutlined />} block className="mt-2">上传 Logo</Button>
|
<Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
|
||||||
|
上传 Logo
|
||||||
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
|
||||||
<Form.Item label={t('platformSettings.icon')} name="iconUrl">
|
<Col xs={24} md={8}>
|
||||||
<Input placeholder="Icon URL" className="mb-2" />
|
<Form.Item label={t("platformSettings.icon")} name="iconUrl">
|
||||||
|
<Input placeholder="Icon URL" style={{ marginBottom: 8 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<ImagePreview url={Form.useWatch('iconUrl', form)} label="Icon" />
|
{renderImagePreview(iconUrl, "Icon")}
|
||||||
<Upload
|
<Upload
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
beforeUpload={(file) => handleUpload(file, 'iconUrl')}
|
beforeUpload={(file) => handleUpload(file, "iconUrl")}
|
||||||
>
|
>
|
||||||
<Button icon={<UploadOutlined />} block className="mt-2">上传 Icon</Button>
|
<Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
|
||||||
|
上传 Icon
|
||||||
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
|
||||||
<Form.Item label={t('platformSettings.loginBg')} name="loginBgUrl">
|
<Col xs={24} md={8}>
|
||||||
<Input placeholder="Background URL" className="mb-2" />
|
<Form.Item label={t("platformSettings.loginBg")} name="loginBgUrl">
|
||||||
|
<Input placeholder="Background URL" style={{ marginBottom: 8 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<ImagePreview url={Form.useWatch('loginBgUrl', form)} label="Background" />
|
{renderImagePreview(loginBgUrl, "Background")}
|
||||||
<Upload
|
<Upload
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
beforeUpload={(file) => handleUpload(file, 'loginBgUrl')}
|
beforeUpload={(file) => handleUpload(file, "loginBgUrl")}
|
||||||
>
|
>
|
||||||
<Button icon={<UploadOutlined />} block className="mt-2">上传背景图</Button>
|
<Button icon={<UploadOutlined />} block style={{ marginTop: 8 }}>
|
||||||
|
上传背景图
|
||||||
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -177,25 +244,80 @@ export default function PlatformSettings() {
|
||||||
|
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card
|
<Card
|
||||||
title={<><FileTextOutlined className="mr-2" /> 合规与版权</>}
|
loading={loading}
|
||||||
className="shadow-sm"
|
style={cardStyle}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<FileTextOutlined style={{ marginRight: 8 }} />
|
||||||
|
合规与版权
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Row gutter={16}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col span={12}>
|
<Col xs={24} md={12}>
|
||||||
<Form.Item label={t('platformSettings.icp')} name="icpInfo">
|
<Form.Item label={t("platformSettings.icp")} name="icpInfo">
|
||||||
<Input placeholder="例如:京 ICP 备 12345678 号" />
|
<Input placeholder="例如:京 ICP 备 12345678 号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col xs={24} md={12}>
|
||||||
<Form.Item label={t('platformSettings.copyright')} name="copyrightInfo">
|
<Form.Item label={t("platformSettings.copyright")} name="copyrightInfo">
|
||||||
<Input placeholder="例如:© 2026 iMeeting Team." />
|
<Input placeholder="例如:© 2026 iMeeting Team. All rights reserved." />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Divider orientation="left" orientationMargin={0}>
|
||||||
|
页面展示预览
|
||||||
|
</Divider>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "#f8fafc",
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: "#1e293b" }}>
|
||||||
|
{projectName || "iMeeting"}
|
||||||
|
</div>
|
||||||
|
<Text style={{ display: "block", marginTop: 8, fontSize: 12, color: "#64748b" }}>
|
||||||
|
保存后将展示在系统底部与登录页底部。
|
||||||
|
</Text>
|
||||||
|
<Divider style={{ margin: "12px 0" }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "8px 24px",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#64748b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{icpInfo || "未填写 ICP 备案号"}</span>
|
||||||
|
<span>{copyrightInfo || "未填写版权信息"}</span>
|
||||||
|
</div>
|
||||||
|
{footerPreview ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
padding: "8px 12px",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#64748b",
|
||||||
|
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footerPreview}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,84 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Table, Card, Button, Input, Space, Modal, Form, Select,
|
Badge,
|
||||||
InputNumber, Tag, message, Popconfirm, Divider, Tooltip,
|
Button,
|
||||||
Radio, Row, Col, Typography, Badge
|
Card,
|
||||||
} from 'antd';
|
Col,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Radio,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined,
|
DeleteOutlined,
|
||||||
UserOutlined, GlobalOutlined
|
EditOutlined,
|
||||||
} from '@ant-design/icons';
|
GlobalOutlined,
|
||||||
import { useTranslation } from 'react-i18next';
|
PlusOutlined,
|
||||||
import { useDict } from '../../hooks/useDict';
|
SearchOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDict } from "../../hooks/useDict";
|
||||||
import {
|
import {
|
||||||
|
deleteHotWord,
|
||||||
getHotWordPage,
|
getHotWordPage,
|
||||||
|
getPinyinSuggestion,
|
||||||
saveHotWord,
|
saveHotWord,
|
||||||
updateHotWord,
|
updateHotWord,
|
||||||
deleteHotWord,
|
type HotWordVO,
|
||||||
getPinyinSuggestion,
|
} from "../../api/business/hotword";
|
||||||
HotWordVO,
|
|
||||||
HotWordDTO
|
|
||||||
} from '../../api/business/hotword';
|
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
type HotWordFormValues = {
|
||||||
|
word: string;
|
||||||
|
pinyin?: string;
|
||||||
|
category?: string;
|
||||||
|
weight: number;
|
||||||
|
status: number;
|
||||||
|
isPublic: number;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const HotWords: React.FC = () => {
|
const HotWords: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm<HotWordFormValues>();
|
||||||
const { items: categories, loading: dictLoading } = useDict('biz_hotword_category');
|
const { items: categories } = useDict("biz_hotword_category");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<HotWordVO[]>([]);
|
const [data, setData] = useState<HotWordVO[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [current, setCurrent] = useState(1);
|
const [current, setCurrent] = useState(1);
|
||||||
const [size, setSize] = useState(10);
|
const [size, setSize] = useState(10);
|
||||||
const [searchWord, setSearchWord] = useState('');
|
const [searchWord, setSearchWord] = useState("");
|
||||||
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
|
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
|
||||||
const [searchType, setSearchType] = useState<number | undefined>(undefined);
|
const [searchType, setSearchType] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
|
|
||||||
// 获取当前用户信息
|
const userProfile = useMemo(() => {
|
||||||
const userProfile = React.useMemo(() => {
|
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
return profileStr ? JSON.parse(profileStr) : {};
|
return profileStr ? JSON.parse(profileStr) : {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 判定是否具有管理员权限 (平台管理员或租户管理员)
|
const isAdmin = useMemo(() => {
|
||||||
const isAdmin = React.useMemo(() => {
|
|
||||||
return userProfile.isPlatformAdmin === true || userProfile.isTenantAdmin === true;
|
return userProfile.isPlatformAdmin === true || userProfile.isTenantAdmin === true;
|
||||||
}, [userProfile]);
|
}, [userProfile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
void fetchData();
|
||||||
}, [current, size, searchWord, searchCategory, searchType]);
|
}, [current, searchCategory, searchType, searchWord, size]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -63,189 +88,224 @@ const HotWords: React.FC = () => {
|
||||||
size,
|
size,
|
||||||
word: searchWord,
|
word: searchWord,
|
||||||
category: searchCategory,
|
category: searchCategory,
|
||||||
isPublic: searchType
|
isPublic: searchType,
|
||||||
});
|
});
|
||||||
if (res.data && res.data.data) {
|
if (res.data?.data) {
|
||||||
setData(res.data.data.records);
|
setData(res.data.data.records);
|
||||||
setTotal(res.data.data.total);
|
setTotal(res.data.data.total);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenModal = (record?: HotWordVO) => {
|
const handleOpenModal = (record?: HotWordVO) => {
|
||||||
if (record) {
|
if (record?.isPublic === 1 && !isAdmin) {
|
||||||
if (record.isPublic === 1 && !isAdmin) {
|
message.error("公开热词仅限管理员修改");
|
||||||
message.error('公开热词仅限管理员修改');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (record) {
|
||||||
setEditingId(record.id);
|
setEditingId(record.id);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...record
|
word: record.word,
|
||||||
|
pinyin: record.pinyinList?.[0] || "",
|
||||||
|
category: record.category,
|
||||||
|
weight: record.weight,
|
||||||
|
status: record.status,
|
||||||
|
isPublic: record.isPublic,
|
||||||
|
remark: record.remark,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ weight: 2, status: 1, isPublic: 0 });
|
form.setFieldsValue({ weight: 2, status: 1, isPublic: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteHotWord(id);
|
||||||
|
message.success("删除成功");
|
||||||
|
await fetchData();
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setSubmitLoading(true);
|
setSubmitLoading(true);
|
||||||
|
|
||||||
const payload: any = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
pinyinList: values.pinyinList
|
matchStrategy: 1,
|
||||||
? (Array.isArray(values.pinyinList) ? values.pinyinList : values.pinyinList.split(',').map((s: string) => s.trim()).filter(Boolean))
|
pinyinList: values.pinyin ? [values.pinyin.trim()] : [],
|
||||||
: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await updateHotWord({ ...payload, id: editingId });
|
await updateHotWord({ ...payload, id: editingId });
|
||||||
message.success('更新成功');
|
message.success("更新成功");
|
||||||
} else {
|
} else {
|
||||||
await saveHotWord(payload);
|
await saveHotWord(payload);
|
||||||
message.success('添加成功');
|
message.success("新增成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
fetchData();
|
await fetchData();
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitLoading(false);
|
setSubmitLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWordBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
|
const handleWordBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
const word = e.target.value;
|
const word = e.target.value?.trim();
|
||||||
if (word) {
|
if (!word || form.getFieldValue("pinyin")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getPinyinSuggestion(word);
|
const res = await getPinyinSuggestion(word);
|
||||||
if (res.data && res.data.data) {
|
const firstPinyin = res.data?.data?.[0];
|
||||||
form.setFieldsValue({ pinyinList: res.data.data.join(', ') });
|
if (firstPinyin) {
|
||||||
}
|
form.setFieldValue("pinyin", firstPinyin);
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '热词原文',
|
title: "热词原文",
|
||||||
dataIndex: 'word',
|
dataIndex: "word",
|
||||||
key: 'word',
|
key: "word",
|
||||||
render: (text: string, record: HotWordVO) => (
|
render: (text: string, record: HotWordVO) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Text strong>{text}</Text>
|
<Text strong>{text}</Text>
|
||||||
{record.isPublic === 1 ? (
|
{record.isPublic === 1 ? (
|
||||||
<Tooltip title="租户公开"><GlobalOutlined style={{ color: '#52c41a' }} /></Tooltip>
|
<Tooltip title="租户公开">
|
||||||
|
<GlobalOutlined style={{ color: "#52c41a" }} />
|
||||||
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title="个人私有"><UserOutlined style={{ color: '#1890ff' }} /></Tooltip>
|
<Tooltip title="个人私有">
|
||||||
|
<UserOutlined style={{ color: "#1890ff" }} />
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '拼音',
|
title: "拼音",
|
||||||
dataIndex: 'pinyinList',
|
dataIndex: "pinyinList",
|
||||||
key: 'pinyinList',
|
key: "pinyinList",
|
||||||
render: (list: string[]) => (
|
render: (list: string[]) =>
|
||||||
<Space size={[0, 4]} wrap>
|
list?.[0] ? <Tag style={{ borderRadius: 4 }}>{list[0]}</Tag> : <Text type="secondary">-</Text>,
|
||||||
{list?.map(p => <Tag key={p} style={{ fontSize: '11px', borderRadius: 4 }}>{p}</Tag>)}
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '类别',
|
title: "类别",
|
||||||
dataIndex: 'category',
|
dataIndex: "category",
|
||||||
key: 'category',
|
key: "category",
|
||||||
render: (val: string) => categories.find(i => i.itemValue === val)?.itemLabel || val
|
render: (value: string) => categories.find((item) => item.itemValue === value)?.itemLabel || value || "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '范围',
|
title: "范围",
|
||||||
dataIndex: 'isPublic',
|
dataIndex: "isPublic",
|
||||||
key: 'isPublic',
|
key: "isPublic",
|
||||||
render: (val: number) => val === 1 ? <Tag color="green">公开</Tag> : <Tag color="blue">私有</Tag>
|
render: (value: number) => (value === 1 ? <Tag color="green">公开</Tag> : <Tag color="blue">私有</Tag>),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '权重',
|
title: "权重",
|
||||||
dataIndex: 'weight',
|
dataIndex: "weight",
|
||||||
key: 'weight',
|
key: "weight",
|
||||||
render: (val: number) => <Tag color="orange">{val}</Tag>
|
render: (value: number) => <Tag color="orange">{value}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: "状态",
|
||||||
dataIndex: 'status',
|
dataIndex: "status",
|
||||||
key: 'status',
|
key: "status",
|
||||||
render: (status: number) => status === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />
|
render: (value: number) =>
|
||||||
|
value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: "操作",
|
||||||
key: 'action',
|
key: "action",
|
||||||
render: (_: any, record: HotWordVO) => {
|
render: (_: unknown, record: HotWordVO) => {
|
||||||
const isMine = record.creatorId === userProfile.userId;
|
const isMine = record.creatorId === userProfile.userId;
|
||||||
const canEdit = record.isPublic === 1 ? isAdmin : (isMine || isAdmin);
|
const canEdit = record.isPublic === 1 ? isAdmin : isMine || isAdmin;
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return <Text type="secondary">无权操作</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
{canEdit ? (
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
|
||||||
<>
|
编辑
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>编辑</Button>
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定删除?"
|
title="确定删除这条热词吗?"
|
||||||
onConfirm={() => handleDelete(record.id)}
|
onConfirm={() => handleDelete(record.id)}
|
||||||
okText={t('common.confirm')}
|
okText={t("common.confirm")}
|
||||||
cancelText={t('common.cancel')}
|
cancelText={t("common.cancel")}
|
||||||
>
|
>
|
||||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</>
|
|
||||||
|
|
||||||
) : (
|
|
||||||
<Text type="secondary" size="small">无权操作</Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px' }}>
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<Card
|
<Card
|
||||||
title="热词库管理"
|
className="flex-1 overflow-hidden shadow-sm"
|
||||||
|
styles={{ body: { padding: 24, height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}
|
||||||
|
title="热词管理"
|
||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select
|
<Select
|
||||||
placeholder="热词类型"
|
placeholder="热词类型"
|
||||||
style={{ width: 110 }}
|
style={{ width: 110 }}
|
||||||
allowClear
|
allowClear
|
||||||
onChange={v => {setSearchType(v); setCurrent(1);}}
|
onChange={(value) => {
|
||||||
|
setSearchType(value);
|
||||||
|
setCurrent(1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Option value={1}>租户公开</Option>
|
<Option value={1}>租户公开</Option>
|
||||||
<Option value={0}>个人私有</Option>
|
<Option value={0}>个人私有</Option>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
placeholder="按类别筛选"
|
placeholder="按类别筛选"
|
||||||
style={{ width: 130 }}
|
style={{ width: 150 }}
|
||||||
allowClear
|
allowClear
|
||||||
onChange={v => {setSearchCategory(v); setCurrent(1);}}
|
onChange={(value) => {
|
||||||
|
setSearchCategory(value);
|
||||||
|
setCurrent(1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{categories.map(c => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
|
{categories.map((item) => (
|
||||||
|
<Option key={item.itemValue} value={item.itemValue}>
|
||||||
|
{item.itemLabel}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索热词原文..."
|
placeholder="搜索热词原文"
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
allowClear
|
allowClear
|
||||||
onPressEnter={(e) => {setSearchWord((e.target as any).value); setCurrent(1);}}
|
onPressEnter={(e) => {
|
||||||
style={{ width: 180 }}
|
setSearchWord((e.target as HTMLInputElement).value);
|
||||||
|
setCurrent(1);
|
||||||
|
}}
|
||||||
|
style={{ width: 200 }}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||||
新增热词
|
新增热词
|
||||||
|
|
@ -253,45 +313,72 @@ const HotWords: React.FC = () => {
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
scroll={{ y: "calc(100vh - 340px)" }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current,
|
current,
|
||||||
pageSize: size,
|
pageSize: size,
|
||||||
total,
|
total,
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
showTotal: (value) => `共 ${value} 条`,
|
||||||
onChange: (p, s) => { setCurrent(p); setSize(s); }
|
onChange: (page, pageSize) => {
|
||||||
|
setCurrent(page);
|
||||||
|
setSize(pageSize);
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editingId ? '编辑热词' : '新增热词'}
|
title={editingId ? "编辑热词" : "新增热词"}
|
||||||
open={modalVisible}
|
open={modalVisible}
|
||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
onCancel={() => setModalVisible(false)}
|
onCancel={() => setModalVisible(false)}
|
||||||
confirmLoading={submitLoading}
|
confirmLoading={submitLoading}
|
||||||
width={550}
|
width={560}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="word"
|
||||||
|
label="热词原文"
|
||||||
|
rules={[{ required: true, message: "请输入热词原文" }]}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: '16px' }}>
|
|
||||||
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: '请输入热词原文' }]}>
|
|
||||||
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="pinyin"
|
||||||
|
label="拼音"
|
||||||
|
tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果"
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:hui yi" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="category" label="热词分类">
|
<Form.Item name="category" label="热词分类">
|
||||||
<Select placeholder="请选择分类" allowClear>
|
<Select placeholder="请选择分类" allowClear>
|
||||||
{categories.map(item => <Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>)}
|
{categories.map((item) => (
|
||||||
|
<Option key={item.itemValue} value={item.itemValue}>
|
||||||
|
{item.itemLabel}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="weight" label="识别权重 (1-5)" tooltip="权重越高,识别引擎越倾向于将其识别为该词">
|
<Form.Item
|
||||||
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: '100%' }} />
|
name="weight"
|
||||||
|
label="识别权重 (1-5)"
|
||||||
|
tooltip="权重越高,识别引擎越倾向于将其识别为该热词"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: "100%" }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -305,20 +392,20 @@ const HotWords: React.FC = () => {
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
{isAdmin && (
|
{isAdmin ? (
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="isPublic" label="租户公开" tooltip="开启后,租户内所有成员均可共享此热词">
|
<Form.Item name="isPublic" label="租户公开" tooltip="开启后,租户内成员都可以共享这条热词">
|
||||||
<Radio.Group>
|
<Radio.Group>
|
||||||
<Radio value={1}>是</Radio>
|
<Radio value={1}>是</Radio>
|
||||||
<Radio value={0}>否</Radio>
|
<Radio value={0}>否</Radio>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="remark" label="备注">
|
<Form.Item name="remark" label="备注">
|
||||||
<Input.TextArea rows={2} placeholder="记录热词的来源或用途" />
|
<Input.TextArea rows={2} placeholder="记录热词来源或适用场景" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue