feat:前端项目页面重构
parent
6c970536b2
commit
d5525496ea
|
|
@ -0,0 +1,362 @@
|
|||
.meeting-create-drawer-root .ant-drawer-content-wrapper {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer .ant-drawer-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__skeleton {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__header {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__title-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #333;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__title.ant-typography {
|
||||
margin: 0 !important;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__subtitle.ant-typography {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch.ant-radio-group {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch .ant-radio-button-wrapper {
|
||||
height: 32px;
|
||||
min-width: 112px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch .anticon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.meeting-create-form {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-form-item-label {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-form-item-label > label {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-input:not(textarea),
|
||||
.meeting-create-form .ant-picker,
|
||||
.meeting-create-form .ant-select-selector,
|
||||
.meeting-create-form .ant-btn {
|
||||
min-height: 32px !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-select-selector {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meeting-create-form .ant-select-multiple .ant-select-selector {
|
||||
height: auto !important;
|
||||
min-height: 32px !important;
|
||||
}
|
||||
|
||||
.meeting-create-form textarea.ant-input {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.meeting-create-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meeting-create-section-title__bar {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 1px;
|
||||
background: #3c70f5;
|
||||
}
|
||||
|
||||
.meeting-create-section-title .ant-typography {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.meeting-create-section-divider {
|
||||
margin: 24px 0;
|
||||
border-top: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.meeting-create-template-grid {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.meeting-create-template-grid .ant-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.meeting-create-template-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.meeting-create-template-card:hover {
|
||||
border-color: #b7cdfd;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.meeting-create-template-card.is-selected {
|
||||
border-color: #3c70f5;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.meeting-create-template-card__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-create-template-card.is-selected .meeting-create-template-card__name {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.meeting-create-template-card__check {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0 4px 0 4px;
|
||||
background: #3c70f5;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meeting-create-radio-line {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meeting-create-advanced.ant-collapse {
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-create-advanced .ant-collapse-header {
|
||||
min-height: 48px;
|
||||
align-items: center !important;
|
||||
padding: 8px 14px !important;
|
||||
}
|
||||
|
||||
.meeting-create-advanced .ant-collapse-content-box {
|
||||
padding: 12px 14px 14px !important;
|
||||
}
|
||||
|
||||
.meeting-create-advanced__label {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meeting-create-advanced__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meeting-create-advanced__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: #eef4ff;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.meeting-create-advanced__body {
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e6e6e6;
|
||||
}
|
||||
|
||||
.meeting-create-upload.ant-upload-wrapper .ant-upload-drag {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload {
|
||||
padding: 28px 16px;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload-drag-icon {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload-drag-icon .anticon {
|
||||
color: #3c70f5;
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload-text {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meeting-create-upload .ant-upload-hint {
|
||||
margin-top: 8px;
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meeting-create-upload__progress {
|
||||
width: min(420px, 80%);
|
||||
margin: 24px auto 0;
|
||||
}
|
||||
|
||||
.meeting-create-upload__progress > div:last-child {
|
||||
margin-top: 8px;
|
||||
color: #3c70f5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meeting-create-upload__file.ant-tag {
|
||||
max-width: 90%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meeting-create-upload__file span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin-left: 4px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__footer .ant-btn {
|
||||
min-width: 112px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.meeting-create-drawer-root .ant-drawer-content-wrapper {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__header,
|
||||
.meeting-create-drawer__body,
|
||||
.meeting-create-drawer__footer {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meeting-create-drawer__type-switch .ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import {
|
|||
} from "../../api/business/meeting";
|
||||
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
|
||||
import type { SysUser } from "../../types";
|
||||
import "./MeetingCreateDrawer.css";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
const { Option } = Select;
|
||||
|
|
@ -195,15 +196,19 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
|
||||
const nextConfig = createConfigRes.data.data || DEFAULT_CREATE_CONFIG;
|
||||
const nextType = resolveAvailableCreateType(initialType, nextConfig);
|
||||
const activePrompts = promptRes.data.data.records.filter((item: PromptTemplateVO) => item.status === 1);
|
||||
const promptRecords = promptRes.data?.data?.records || [];
|
||||
const asrRecords = asrRes.data?.data?.records || [];
|
||||
const llmRecords = llmRes.data?.data?.records || [];
|
||||
const hotwordRecords = hotwordRes.data?.data?.records || [];
|
||||
const activePrompts = promptRecords.filter((item: PromptTemplateVO) => item.status === 1);
|
||||
|
||||
setCreateConfig(nextConfig);
|
||||
setConfigLoaded(true);
|
||||
setType(nextType);
|
||||
setAsrModels(asrRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
|
||||
setLlmModels(llmRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
|
||||
setAsrModels(asrRecords.filter((item: AiModelVO) => item.status === 1));
|
||||
setLlmModels(llmRecords.filter((item: AiModelVO) => item.status === 1));
|
||||
setPrompts(activePrompts);
|
||||
setHotwordList(hotwordRes.data.data.records.filter((item: HotWordVO) => item.status === 1));
|
||||
setHotwordList(hotwordRecords.filter((item: HotWordVO) => item.status === 1));
|
||||
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
|
||||
setUserList(users || []);
|
||||
|
||||
|
|
@ -387,20 +392,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
|
||||
return (
|
||||
<Drawer
|
||||
rootClassName="meeting-create-drawer-root"
|
||||
className="meeting-create-drawer"
|
||||
title={null}
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
width={960}
|
||||
width="min(960px, calc(100vw - 48px))"
|
||||
forceRender
|
||||
destroyOnClose={false}
|
||||
placement="right"
|
||||
closable={false}
|
||||
footer={
|
||||
configLoaded ? (
|
||||
<div style={{ textAlign: "right", padding: "16px 32px" }}>
|
||||
<Space size={16}>
|
||||
<Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}>取消</Button>
|
||||
<Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}>
|
||||
<div className="meeting-create-drawer__footer">
|
||||
<Space size={12}>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button type="primary" onClick={handleOk} loading={submitting}>
|
||||
{type === "upload" ? (audioUrl ? "完成并分析" : "上传并分析") : "创建并进入识别"}
|
||||
</Button>
|
||||
</Space>
|
||||
|
|
@ -414,117 +421,120 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
}}
|
||||
>
|
||||
{!configLoaded ? (
|
||||
<div style={{ padding: "40px" }}>
|
||||
<div className="meeting-create-drawer__skeleton">
|
||||
<Skeleton active paragraph={{ rows: 12 }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ background: "var(--app-bg-surface)", padding: "24px 32px", borderBottom: "1px solid var(--app-border-color)" }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space size={16}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 12, background: "var(--app-bg-surface-strong)", color: "var(--app-text-main)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, border: "1px solid var(--app-border-color)" }}>
|
||||
<div className="meeting-create-drawer__header">
|
||||
<Row justify="space-between" align="middle" gutter={[16, 12]}>
|
||||
<Col flex="auto">
|
||||
<Space size={12}>
|
||||
<div className="meeting-create-drawer__title-icon">
|
||||
{type === "upload" ? <CloudUploadOutlined /> : <AudioOutlined />}
|
||||
</div>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
|
||||
<Title level={4} className="meeting-create-drawer__title">{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
|
||||
<Text type="secondary" className="meeting-create-drawer__subtitle">{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
|
||||
<Col flex="none">
|
||||
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" className="meeting-create-drawer__type-switch">
|
||||
{createConfig.offlineEnabled && (
|
||||
<Radio.Button value="upload" style={{ padding: "0 24px" }}><CloudUploadOutlined style={{ marginRight: 6 }} /> 上传录音</Radio.Button>
|
||||
<Radio.Button value="upload"><CloudUploadOutlined /> 上传录音</Radio.Button>
|
||||
)}
|
||||
{createConfig.realtimeEnabled && (
|
||||
<Radio.Button value="realtime" style={{ padding: "0 24px" }}><AudioOutlined style={{ marginRight: 6 }} /> 实时会议</Radio.Button>
|
||||
<Radio.Button value="realtime"><AudioOutlined /> 实时会议</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "32px 40px", flex: 1, overflowY: "auto", background: "var(--app-bg-layout)" }}>
|
||||
<Form form={form} layout="vertical" disabled={loading}>
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>基础信息</Title>
|
||||
<div className="meeting-create-drawer__body">
|
||||
<Form form={form} layout="vertical" disabled={loading} className="meeting-create-form">
|
||||
<div className="meeting-create-section-title">
|
||||
<span className="meeting-create-section-title__bar" />
|
||||
<Title level={5}>基础信息</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
|
||||
<Input placeholder="输入会议标题" size="large" />
|
||||
<Input placeholder="输入会议标题" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
|
||||
<DatePicker showTime style={{ width: "100%" }} size="large" />
|
||||
<DatePicker showTime style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="participants" label="参会人员">
|
||||
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children" size="large">
|
||||
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="hostUserId" label="会议主持人">
|
||||
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children" size="large">
|
||||
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Row gutter={24}>
|
||||
<Col span={24}>
|
||||
<Form.Item name="tags" label="会议标签">
|
||||
<Select mode="tags" placeholder="输入标签" size="large" />
|
||||
<Select mode="tags" placeholder="输入标签" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
|
||||
<div className="meeting-create-section-divider" />
|
||||
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>模型与纪要配置</Title>
|
||||
<div className="meeting-create-section-title">
|
||||
<span className="meeting-create-section-title__bar" />
|
||||
<Title level={5}>模型与纪要配置</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="asrModelId" label="语音识别模型 (ASR)" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
<Select placeholder="选择 ASR 模型">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
<Select placeholder="选择总结模型">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
|
||||
{prompts.length > 15 ? (
|
||||
<Select placeholder="请选择总结模板" showSearch optionFilterProp="children" size="large">
|
||||
<Select placeholder="请选择总结模板" showSearch optionFilterProp="children">
|
||||
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<div style={{ padding: "2px" }}>
|
||||
<div className="meeting-create-template-grid">
|
||||
<Row gutter={[12, 12]}>
|
||||
{prompts.map(p => {
|
||||
const isSelected = watchedPromptId === p.id;
|
||||
return (
|
||||
<Col span={8} key={p.id}>
|
||||
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: "12px 16px", borderRadius: 8, border: `1px solid ${isSelected ? "#1890ff" : "var(--app-border-color)"}`, background: isSelected ? "#e6f7ff" : "var(--app-bg-surface)", cursor: "pointer", position: "relative", transition: "all 0.2s", display: "flex", alignItems: "center", height: "100%" }}>
|
||||
<div style={{ fontSize: "14px", color: isSelected ? "#1890ff" : "var(--app-text-main)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
|
||||
{isSelected && <div style={{ position: "absolute", top: -1, right: -1, width: 20, height: 20, background: "#1890ff", borderRadius: "0 8px 0 8px", display: "flex", alignItems: "center", justifyContent: "center" }}><CheckOutlined style={{ color: "#fff", fontSize: 12 }} /></div>}
|
||||
<Col xs={24} sm={12} md={8} key={p.id}>
|
||||
<div
|
||||
onClick={() => form.setFieldsValue({ promptId: p.id })}
|
||||
className={isSelected ? "meeting-create-template-card is-selected" : "meeting-create-template-card"}
|
||||
>
|
||||
<div className="meeting-create-template-card__name">{p.templateName}</div>
|
||||
{isSelected && <div className="meeting-create-template-card__check"><CheckOutlined /></div>}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
|
|
@ -534,15 +544,15 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="hotWordGroupId" label="热词组" tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : "模板未绑定热词组时可手动选择"} extra={watchedHotWordGroupId != null ? "创建会议时会优先使用这里选中的热词组" : undefined}>
|
||||
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} size="large" options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
|
||||
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
|
||||
<Radio.Group size="large">
|
||||
<Radio.Group className="meeting-create-radio-line">
|
||||
<Radio value="DETAILED">详细</Radio>
|
||||
<Radio value="STANDARD">标准</Radio>
|
||||
<Radio value="BRIEF">简洁</Radio>
|
||||
|
|
@ -554,37 +564,37 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
<Collapse
|
||||
ghost
|
||||
expandIconPosition="end"
|
||||
style={{ marginBottom: 24, background: "var(--app-bg-surface)", border: "1px solid var(--app-border-color)", borderRadius: 8, overflow: "hidden" }}
|
||||
className="meeting-create-advanced"
|
||||
items={[
|
||||
{
|
||||
key: "advanced",
|
||||
forceRender: true,
|
||||
label: (
|
||||
<div style={{ display: "flex", alignItems: "center", width: "100%", height: "32px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", fontWeight: 600, color: "var(--app-text-main)", fontSize: 15 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: "#f0f5ff", color: "#1677ff", display: "flex", alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<SettingOutlined style={{ fontSize: 16 }} />
|
||||
<div className="meeting-create-advanced__label">
|
||||
<div className="meeting-create-advanced__title">
|
||||
<div className="meeting-create-advanced__icon">
|
||||
<SettingOutlined />
|
||||
</div>
|
||||
高级设置
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 8, borderTop: "1px dashed var(--app-border-color)" }}>
|
||||
<Row gutter={32}>
|
||||
<Col span={8}>
|
||||
<div className="meeting-create-advanced__body">
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="useSpkId" label={<span>说话人区分 <Tooltip title="自动识别录音中的不同发言者并进行角色分离"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="enableTextRefine" label={<span>文本修正 <Tooltip title="自动识别并修正语音转写中的口语词、语气助词等"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{type === "realtime" && (
|
||||
<>
|
||||
<Col span={8}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="saveAudio" label="保存录音" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
|
@ -611,10 +621,10 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
|
||||
{type === "upload" && (
|
||||
<>
|
||||
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>上传录音文件</Title>
|
||||
<div className="meeting-create-section-divider" />
|
||||
<div className="meeting-create-section-title">
|
||||
<span className="meeting-create-section-title__bar" />
|
||||
<Title level={5}>上传录音文件</Title>
|
||||
</div>
|
||||
<Dragger
|
||||
accept=".mp3,.wav,.m4a"
|
||||
|
|
@ -623,22 +633,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
customRequest={customUpload}
|
||||
onChange={info => setFileList(info.fileList.slice(-1))}
|
||||
maxCount={1}
|
||||
style={{ borderRadius: 12, padding: "32px 0", background: "var(--app-bg-surface)", border: "1px dashed var(--app-border-color)" }}
|
||||
className="meeting-create-upload"
|
||||
>
|
||||
<div>
|
||||
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: "#1890ff" }} /></p>
|
||||
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: "var(--app-text-main)" }}>点击或拖拽录音文件到此处</p>
|
||||
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: "var(--app-text-secondary)" }}>支持 mp3、wav、m4a 等格式,大小不超过 {createConfig.offlineAudioMaxSizeMb}MB</p>
|
||||
<p className="ant-upload-drag-icon"><CloudUploadOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽录音文件到此处</p>
|
||||
<p className="ant-upload-hint">支持 mp3、wav、m4a 等格式,大小不超过 {createConfig.offlineAudioMaxSizeMb}MB</p>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div style={{ width: "60%", margin: "32px auto 0" }}>
|
||||
<div className="meeting-create-upload__progress">
|
||||
<Progress percent={uploadProgress} size="small" />
|
||||
<div style={{ fontSize: 13, color: "#1890ff", marginTop: 8 }}>文件上传中,请稍候...</div>
|
||||
<div>文件上传中,请稍候...</div>
|
||||
</div>
|
||||
)}
|
||||
{audioUrl && (
|
||||
<Tag color="processing" style={{ marginTop: 24, padding: "6px 16px", fontSize: 14, borderRadius: 6, maxWidth: "90%", display: "inline-flex", alignItems: "center" }}>
|
||||
<Tag color="processing" className="meeting-create-upload__file">
|
||||
<span>已上传:</span>
|
||||
<span style={{ marginLeft: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{audioUrl.split("/").pop()}</span>
|
||||
<span>{audioUrl.split("/").pop()}</span>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
|
@ -78,10 +78,10 @@
|
|||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 40px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
|
@ -97,10 +97,26 @@
|
|||
.data-list-panel__right-actions {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.data-list-panel__toolbar .ant-btn {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.data-list-panel__toolbar .ant-btn:not(.ant-btn-icon-only) {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.data-list-panel__toolbar .ant-btn .ant-btn-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-list-panel__right-actions .ant-space {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
|
|
@ -152,6 +168,11 @@
|
|||
font-weight: 400;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected):hover > td,
|
||||
.data-list-panel__table-area .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected) > td.ant-table-cell-row-hover {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.data-list-panel__table-area .ant-table-cell {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
|
@ -170,7 +191,7 @@
|
|||
.data-list-panel__footer {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.data-list-panel__footer .app-pagination-container {
|
||||
|
|
@ -179,6 +200,7 @@
|
|||
border-radius: 0;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.data-list-panel__footer .app-pagination-container .ant-pagination {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@
|
|||
background: #fff;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-wrapper {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 行选中样式 */
|
||||
.list-table-container .row-selected > td {
|
||||
background-color: var(--item-hover-bg) !important;
|
||||
|
|
@ -65,8 +69,16 @@
|
|||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-tbody > tr:hover > td {
|
||||
background: #f5f9ff !important;
|
||||
.list-table-container .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):hover > td {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected) > td.ant-table-cell-row-hover {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.list-table-container .ant-table-tbody > tr.row-selected > td.ant-table-cell-row-hover {
|
||||
background-color: var(--item-hover-bg) !important;
|
||||
}
|
||||
|
||||
.list-table-container .list-table-table--y-scroll.ant-table-wrapper,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px 16px;
|
||||
padding: 0 16px 12px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
.page-header-standard {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header-standard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.page-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.page-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.page-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-header-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.page-header-description {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-header-extra {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header-standard {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header-extra {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import './PageHeader.css'
|
||||
|
||||
function PageHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
showBack = false,
|
||||
onBack,
|
||||
extra
|
||||
}) {
|
||||
return (
|
||||
<div className="page-header-standard">
|
||||
<div className="page-header-main">
|
||||
{showBack && (
|
||||
<button className="back-button" onClick={onBack}>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
)}
|
||||
<div className="page-header-content">
|
||||
{icon && <div className="page-header-icon">{icon}</div>}
|
||||
<div className="page-header-text">
|
||||
<h1 className="page-header-title">{title}</h1>
|
||||
{description && (
|
||||
<p className="page-header-description">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{extra && <div className="page-header-extra">{extra}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHeader
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { Typography } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
extra?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, extra, className = '' }) => {
|
||||
return (
|
||||
<div
|
||||
className={`page-header ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 16
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Title level={4} style={{ margin: '0 0 8px 0', fontWeight: 600 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{subtitle && (
|
||||
<Text type="secondary" style={{ display: 'block', fontSize: 14 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{extra && <div className="page-header-extra" style={{ display: 'flex', alignItems: 'center' }}>{extra}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
.page-title-bar {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.page-title-bar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -5%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: rgba(139, 92, 246, 0.05);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.title-bar-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-bar-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.title-badge {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: #7c3aed;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title-actions button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title-actions button.primary {
|
||||
background: #7c3aed;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.title-actions button.primary:hover {
|
||||
background: #6d28d9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25);
|
||||
}
|
||||
|
||||
.title-actions button.secondary {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #7c3aed;
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.title-actions button.secondary:hover {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
color: #7c3aed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 扩展内容区域 */
|
||||
.title-bar-expanded-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(139, 92, 246, 0.1);
|
||||
animation: expandContent 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes expandContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 768px) {
|
||||
.title-bar-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.title-bar-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons'
|
||||
import './PageTitleBar.css'
|
||||
|
||||
function PageTitleBar({
|
||||
title,
|
||||
badge,
|
||||
description,
|
||||
actions,
|
||||
showToggle = false,
|
||||
onToggle,
|
||||
defaultExpanded = false,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
|
||||
const handleToggle = () => {
|
||||
const newExpanded = !expanded
|
||||
setExpanded(newExpanded)
|
||||
if (onToggle) {
|
||||
onToggle(newExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-title-bar">
|
||||
<div className="title-bar-content">
|
||||
<div className="title-bar-left">
|
||||
<div className="title-row">
|
||||
<div className="title-group">
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{badge && <span className="title-badge">{badge}</span>}
|
||||
</div>
|
||||
{description && <p className="page-description">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="title-bar-right">
|
||||
{actions && <div className="title-actions">{actions}</div>}
|
||||
{showToggle && (
|
||||
<button
|
||||
className="toggle-button"
|
||||
onClick={handleToggle}
|
||||
title={expanded ? '收起信息面板' : '展开信息面板'}
|
||||
>
|
||||
{expanded ? <UpOutlined /> : <DownOutlined />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageTitleBar
|
||||
|
|
@ -93,6 +93,10 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-card__tabs:has(+ .section-card__content .data-list-panel) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.section-card__tabs > .ant-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -121,13 +125,20 @@
|
|||
border: 0 solid transparent !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: rgba(249, 250, 254, 0) !important;
|
||||
transition: none !important;
|
||||
transition: background-color 0.16s ease !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
.section-card__tabs .ant-tabs-tab:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active,
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active:hover,
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active:focus,
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active:active {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: #f9fafe !important;
|
||||
background-color: #e9eef8 !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab-btn {
|
||||
|
|
@ -136,7 +147,7 @@
|
|||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0;
|
||||
transition: none !important;
|
||||
transition: color 0.16s ease !important;
|
||||
}
|
||||
|
||||
.section-card__tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
|
|
|
|||
|
|
@ -270,16 +270,53 @@ body::after {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-page__page-actions {
|
||||
.app-page__content-toolbar {
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin: -8px 0 16px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-page__page-actions .ant-btn {
|
||||
min-width: 96px;
|
||||
border-radius: 10px !important;
|
||||
.app-page__content-toolbar-actions,
|
||||
.app-page__content-toolbar-filters {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-filters {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar .ant-btn {
|
||||
height: 32px;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar .ant-btn:not(.ant-btn-icon-only) {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar .ant-input,
|
||||
.app-page__content-toolbar .ant-input-affix-wrapper,
|
||||
.app-page__content-toolbar .ant-select-selector,
|
||||
.app-page__content-toolbar .ant-picker,
|
||||
.app-page__content-toolbar .ant-input-number {
|
||||
height: 32px !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.app-page__toolbar .ant-input,
|
||||
|
|
@ -838,6 +875,21 @@ body::after {
|
|||
.app-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-actions,
|
||||
.app-page__content-toolbar-filters,
|
||||
.app-page__content-toolbar-filters .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-page__content-toolbar-filters {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -884,6 +936,13 @@ body::after {
|
|||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.app-page__table-wrap .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected):hover > td,
|
||||
.app-page__table-wrap .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected) > td.ant-table-cell-row-hover,
|
||||
.app-page__panel-card .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected):hover > td,
|
||||
.app-page__panel-card .ant-table-tbody > tr:not(.row-selected):not(.ant-table-row-selected):not(.dict-type-row-selected) > td.ant-table-cell-row-hover {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination,
|
||||
.app-global-pagination.ant-pagination {
|
||||
margin: auto 0 0 0 !important;
|
||||
|
|
@ -1253,7 +1312,6 @@ body::after,
|
|||
.app-page__toolbar .ant-picker,
|
||||
.app-page__toolbar .ant-input-number,
|
||||
.app-page__toolbar .ant-btn,
|
||||
.app-page__page-actions .ant-btn,
|
||||
.ant-btn,
|
||||
.ant-input,
|
||||
.ant-input-affix-wrapper,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
.permissions-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.permissions-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.permissions-content-card {
|
||||
|
|
@ -9,9 +17,9 @@
|
|||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 18px !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04) !important;
|
||||
border-radius: 4px !important;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
box-shadow: none !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +81,7 @@
|
|||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ import { CheckSquareOutlined, ClusterOutlined, DeleteOutlined, EditOutlined, Fol
|
|||
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import type { SysPermission } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
|
|
@ -419,18 +420,21 @@ export default function Permissions() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("permissions.title")}
|
||||
subtitle={t("permissions.subtitle")}
|
||||
headerExtra={
|
||||
can("sys:permission:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
<>
|
||||
<PageContainer title={null} className="permissions-page">
|
||||
<SectionCard
|
||||
title={t("permissions.title")}
|
||||
description={t("permissions.subtitle")}
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
can("sys:permission:create") ? (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder={t("permissions.permName")}
|
||||
value={query.name}
|
||||
|
|
@ -463,9 +467,9 @@ export default function Permissions() {
|
|||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={() => setQuery({ name: "", code: "", permType: "" })}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<DndContext sensors={sensors} modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={flatVisualKeys} strategy={verticalListSortingStrategy}>
|
||||
<Table
|
||||
|
|
@ -486,6 +490,8 @@ export default function Permissions() {
|
|||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer
|
||||
title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
.roles-page-v2 {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.roles-page-v2 > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.roles-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.roles-layout__row {
|
||||
|
|
@ -28,11 +35,30 @@
|
|||
.roles-detail-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 18px !important;
|
||||
border-radius: 4px !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04) !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8) !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
overflow: hidden;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.roles-side-card > .ant-card-head,
|
||||
.roles-detail-card > .ant-card-head {
|
||||
flex-shrink: 0;
|
||||
min-height: 55px;
|
||||
border-bottom: 1px solid #eef1f6 !important;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.roles-side-card > .ant-card-head .ant-card-head-title,
|
||||
.roles-detail-card > .ant-card-head .ant-card-head-title {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.roles-detail-card > .ant-card-head .ant-card-extra {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.roles-side-card .ant-card-body,
|
||||
|
|
@ -41,7 +67,7 @@
|
|||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px !important;
|
||||
padding: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -49,22 +75,22 @@
|
|||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-search-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-search-bar .ant-input-affix-wrapper {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.role-search-bar .ant-btn {
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.role-list-container-v3 {
|
||||
|
|
@ -72,8 +98,8 @@
|
|||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 8px;
|
||||
margin-right: -8px;
|
||||
padding-right: 0;
|
||||
margin-right: 0;
|
||||
|
||||
/* Modern scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
|
|
@ -84,7 +110,7 @@
|
|||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
|
@ -95,26 +121,24 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
border-color: #c8d8ff;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
border-color: #9cb8ff;
|
||||
background: #f3f7ff;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
|
@ -123,16 +147,16 @@
|
|||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #3b82f6;
|
||||
background: #3c70f5;
|
||||
}
|
||||
|
||||
.role-name {
|
||||
color: #1e3a8a;
|
||||
color: #2f5edb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-item-symbol {
|
||||
background: #3b82f6;
|
||||
background: #3c70f5;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
|
@ -141,9 +165,9 @@
|
|||
.role-item-symbol {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-radius: 4px;
|
||||
background: #eef4ff;
|
||||
color: #3c70f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -207,7 +231,7 @@
|
|||
|
||||
.role-list-pagination {
|
||||
flex-shrink: 0;
|
||||
padding-top: 16px;
|
||||
padding-top: 8px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
|
|
@ -224,9 +248,9 @@
|
|||
.role-detail-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
background: #eef4ff;
|
||||
color: #3c70f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -263,7 +287,8 @@
|
|||
min-height: 0;
|
||||
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 16px !important;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px !important;
|
||||
&::before {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
|
@ -272,7 +297,7 @@
|
|||
.ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
|
@ -282,20 +307,45 @@
|
|||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content,
|
||||
.ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.role-detail-pane {
|
||||
padding: 4px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d8dfeb;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.permission-tree-wrapper {
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
min-height: 100%;
|
||||
padding: 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.role-members-toolbar {
|
||||
|
|
@ -321,8 +371,8 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.5);
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #cccccc;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ import {
|
|||
} from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
|
||||
import "./index.less";
|
||||
|
|
@ -422,200 +424,215 @@ export default function Roles() {
|
|||
const saveLabel = "同步保存";
|
||||
|
||||
return (
|
||||
<div className="app-page roles-page-v2">
|
||||
<PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
|
||||
<PageContainer title={null} className="roles-page-v2">
|
||||
<SectionCard
|
||||
title="角色管理"
|
||||
description="维护角色基础信息、功能权限、数据权限与成员绑定。"
|
||||
>
|
||||
<DataListPanel
|
||||
className="roles-data-panel"
|
||||
toolbarClassName="roles-data-panel__toolbar"
|
||||
leftActions={
|
||||
can("sys:role:create") ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增角色
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="roles-layout">
|
||||
<Row gutter={8} className="roles-layout__row">
|
||||
<Col span={7} className="roles-layout__side">
|
||||
<Card
|
||||
title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>}
|
||||
bordered={false}
|
||||
className="app-page__panel-card roles-side-card"
|
||||
>
|
||||
<div className="role-search-panel">
|
||||
{isPlatformMode && (
|
||||
<Select
|
||||
placeholder="按租户筛选"
|
||||
style={{ width: "100%" }}
|
||||
allowClear
|
||||
suffixIcon={<FilterOutlined />}
|
||||
value={filterTenantId}
|
||||
onChange={(value) => { setFilterTenantId(normalizeNumber(value)); setRolePage((prev) => ({ ...prev, current: 1 })); }}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
/>
|
||||
)}
|
||||
<div className="role-search-bar">
|
||||
<Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
|
||||
<Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-page__page-actions">
|
||||
{can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{"新增角色"}</Button>}
|
||||
</div>
|
||||
|
||||
<div className="roles-layout">
|
||||
<Row gutter={24} className="roles-layout__row">
|
||||
<Col span={7} className="roles-layout__side">
|
||||
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
|
||||
<div className="role-search-panel">
|
||||
{isPlatformMode && (
|
||||
<Select
|
||||
placeholder="按租户筛选"
|
||||
style={{ width: "100%" }}
|
||||
allowClear
|
||||
suffixIcon={<FilterOutlined />}
|
||||
value={filterTenantId}
|
||||
onChange={(value) => { setFilterTenantId(normalizeNumber(value)); setRolePage((prev) => ({ ...prev, current: 1 })); }}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
/>
|
||||
)}
|
||||
<div className="role-search-bar">
|
||||
<Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
|
||||
<Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="role-list-container-v3">
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
renderItem={(item) => (
|
||||
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
|
||||
<div className="role-item-symbol" aria-hidden="true">
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div className="role-item-main">
|
||||
<div className="role-item-name-row">
|
||||
<Text strong className="role-name">{item.roleName}</Text>
|
||||
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
|
||||
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
|
||||
<div className="role-list-container-v3">
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
renderItem={(item) => (
|
||||
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
|
||||
<div className="role-item-symbol" aria-hidden="true">
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div className="role-item-main">
|
||||
<div className="role-item-name-row">
|
||||
<Text strong className="role-name">{item.roleName}</Text>
|
||||
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
|
||||
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
|
||||
</div>
|
||||
<Text type="secondary" className="role-code">{item.roleCode}</Text>
|
||||
</div>
|
||||
{selectedRole?.roleId === item.roleId ? (
|
||||
<div className="role-item-selected-mark" aria-hidden="true">
|
||||
<CheckCircleFilled />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="role-item-actions">
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
|
||||
</Tooltip>
|
||||
{item.roleCode !== "ADMIN" && (
|
||||
<Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<Text type="secondary" className="role-code">{item.roleCode}</Text>
|
||||
</div>
|
||||
{selectedRole?.roleId === item.roleId ? (
|
||||
<div className="role-item-selected-mark" aria-hidden="true">
|
||||
<CheckCircleFilled />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="role-list-pagination">
|
||||
<Pagination
|
||||
{...getStandardPagination(rolePage.total, rolePage.current, rolePage.size, handleRolePageChange, { size: "small", showSizeChanger: true, pageSizeOptions: ["10", "20", "50"] })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={17} className="roles-layout__detail">
|
||||
{selectedRole ? (
|
||||
<Card
|
||||
className="app-page__panel-card roles-detail-card"
|
||||
bordered={false}
|
||||
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
|
||||
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
|
||||
<Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
|
||||
<div className="role-detail-pane">
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
checkStrictly={false}
|
||||
treeData={permissionTreeData}
|
||||
checkedKeys={selectedPermIds}
|
||||
onCheck={(keys, info) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
const halfChecked = info.halfCheckedKeys || [];
|
||||
setSelectedPermIds(checked.map((key) => Number(key)));
|
||||
setHalfCheckedIds(halfChecked.map((key) => Number(key)));
|
||||
setPermissionsDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="role-item-actions">
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
|
||||
</Tooltip>
|
||||
{item.roleCode !== "ADMIN" && (
|
||||
<Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
|
||||
<div className="role-detail-pane">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={dataScopeType}
|
||||
onChange={(event) => {
|
||||
setDataScopeType(event.target.value);
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
{DATA_SCOPE_OPTIONS.map((item) => (
|
||||
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
|
||||
{dataScopeType === "CUSTOM" ? (
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
treeData={scopeOrgTree}
|
||||
checkedKeys={scopeOrgIds}
|
||||
onCheck={(keys) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
setScopeOrgIds(checked.map((key) => Number(key)));
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="role-list-pagination">
|
||||
<Pagination
|
||||
{...getStandardPagination(rolePage.total, rolePage.current, rolePage.size, handleRolePageChange, { size: "small", showSizeChanger: true, pageSizeOptions: ["10", "20", "50"] })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={17} className="roles-layout__detail">
|
||||
{selectedRole ? (
|
||||
<Card
|
||||
className="app-page__panel-card roles-detail-card"
|
||||
bordered={false}
|
||||
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
|
||||
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
|
||||
<Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
|
||||
<div className="role-detail-pane">
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
checkStrictly={false}
|
||||
treeData={permissionTreeData}
|
||||
checkedKeys={selectedPermIds}
|
||||
onCheck={(keys, info) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
const halfChecked = info.halfCheckedKeys || [];
|
||||
setSelectedPermIds(checked.map((key) => Number(key)));
|
||||
setHalfCheckedIds(halfChecked.map((key) => Number(key)));
|
||||
setPermissionsDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
|
||||
<div className="role-detail-pane">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={dataScopeType}
|
||||
onChange={(event) => {
|
||||
setDataScopeType(event.target.value);
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
{DATA_SCOPE_OPTIONS.map((item) => (
|
||||
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
|
||||
{dataScopeType === "CUSTOM" ? (
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
treeData={scopeOrgTree}
|
||||
checkedKeys={scopeOrgIds}
|
||||
onCheck={(keys) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
setScopeOrgIds(checked.map((key) => Number(key)));
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
|
||||
<div className="role-detail-pane">
|
||||
<div className="role-members-toolbar">
|
||||
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
|
||||
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
loading={loadingUsers}
|
||||
dataSource={roleUsers}
|
||||
pagination={{ ...getStandardPagination(roleUsers.length, 1, 10, undefined, { size: "small", showSizeChanger: false }), current: undefined }}
|
||||
columns={[
|
||||
{
|
||||
title: "用户信息",
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
|
||||
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
|
||||
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 80,
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
|
||||
)}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
|
||||
<div className="role-detail-pane">
|
||||
<div className="role-members-toolbar">
|
||||
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
|
||||
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
loading={loadingUsers}
|
||||
dataSource={roleUsers}
|
||||
pagination={{ ...getStandardPagination(roleUsers.length, 1, 10, undefined, { size: "small", showSizeChanger: false }), current: undefined }}
|
||||
columns={[
|
||||
{
|
||||
title: "用户信息",
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
|
||||
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
|
||||
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 80,
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
|
|
@ -643,6 +660,6 @@ export default function Roles() {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
.users-page {
|
||||
min-height: 100%;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.users-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.users-header {
|
||||
|
|
@ -14,8 +24,8 @@
|
|||
}
|
||||
|
||||
.users-table-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.users-table-toolbar {
|
||||
|
|
@ -32,8 +42,8 @@
|
|||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px 14px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95) 0%, rgba(255, 255, 255, 0.75) 100%);
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.users-content-header__count {
|
||||
|
|
|
|||
|
|
@ -52,11 +52,13 @@ import {
|
|||
import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
|
||||
import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
|
|
@ -476,14 +478,21 @@ export default function Users() {
|
|||
];
|
||||
|
||||
return (
|
||||
<div className="app-page users-page">
|
||||
<PageHeader title={t("users.title")} subtitle={t("users.subtitle")}/>
|
||||
|
||||
<Card className="users-table-card app-page__filter-card" styles={{body: {padding: "16px"}}}>
|
||||
<div className="users-table-toolbar">
|
||||
<Space size="middle" wrap className="app-page__toolbar"
|
||||
style={{justifyContent: "space-between", width: "100%"}}>
|
||||
<Space size="middle" wrap className="app-page__toolbar">
|
||||
<PageContainer title={null} className="users-page">
|
||||
<SectionCard
|
||||
title={t("users.title")}
|
||||
description={t("users.subtitle")}
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
can("sys:user:create") ? (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
rightActions={
|
||||
<Space size={8} wrap>
|
||||
{isPlatformMode &&
|
||||
<Select placeholder={t("users.tenantFilter")} style={{width: 200}} allowClear value={filterTenantId}
|
||||
onChange={setFilterTenantId}
|
||||
|
|
@ -498,27 +507,29 @@ export default function Users() {
|
|||
onClick={handleSearch}>{t("common.search")}</Button>
|
||||
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true"/>}
|
||||
onClick={openCreate}>{t("common.create")}</Button>}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden"
|
||||
styles={{body: {padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden"}}}>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{y: "calc(100vh - 430px)"}}
|
||||
pagination={getStandardPagination(filteredData.length, current, pageSize, (page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={pageSize}
|
||||
total={filteredData.length}
|
||||
onChange={(page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable
|
||||
rowKey="userId"
|
||||
columns={columns}
|
||||
dataSource={filteredData.slice((current - 1) * pageSize, current * pageSize)}
|
||||
loading={loading}
|
||||
scroll={{ y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2"
|
||||
aria-hidden="true"/>{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}
|
||||
|
|
@ -706,6 +717,6 @@ export default function Users() {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
padding: 32px 24px;
|
||||
background: radial-gradient(circle at top left, rgba(22, 119, 255, 0.16), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #eef4fb 0%, #f7f9fc 100%);
|
||||
background: #f5f6fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -14,19 +12,17 @@
|
|||
min-height: 680px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.92fr) minmax(420px, 1fr);
|
||||
border-radius: 28px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.auth-shell__aside {
|
||||
padding: 56px 48px;
|
||||
color: #f8fbff;
|
||||
background: radial-gradient(circle at top left, rgba(12, 74, 110, 0.4), transparent 50%),
|
||||
linear-gradient(165deg, #0b1b36 0%, #0c4a6e 60%, #0369a1 100%);
|
||||
color: #333333;
|
||||
background: #f9fafe;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
|
@ -35,13 +31,7 @@
|
|||
}
|
||||
|
||||
.auth-shell__aside::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -80px -120px auto;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0) 68%);
|
||||
content: none;
|
||||
}
|
||||
|
||||
.auth-shell__aside-top {
|
||||
|
|
@ -57,28 +47,26 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
color: #3c70f5;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra {
|
||||
padding: 16px 20px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra-title {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -86,13 +74,13 @@
|
|||
.auth-shell__aside-extra-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
color: #606775;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra-list li::marker {
|
||||
color: #38bdf8;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.auth-shell__aside-copy {
|
||||
|
|
@ -103,15 +91,15 @@
|
|||
|
||||
.auth-shell__aside-title.ant-typography {
|
||||
margin: 0 0 16px;
|
||||
color: #ffffff;
|
||||
font-size: 36px;
|
||||
color: #333333;
|
||||
font-size: 32px;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.8px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.auth-shell__aside-description.ant-typography {
|
||||
margin: 0;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
color: #606775;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
|
@ -129,18 +117,18 @@
|
|||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.auth-shell__highlight-item .anticon {
|
||||
margin-top: 4px;
|
||||
color: #7dd3fc;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.auth-shell__highlight-item .ant-typography {
|
||||
color: rgba(241, 245, 249, 0.94);
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.auth-shell__main {
|
||||
|
|
@ -160,10 +148,10 @@
|
|||
|
||||
.auth-shell__panel-title.ant-typography {
|
||||
margin: 0 0 8px;
|
||||
color: #10233b;
|
||||
font-size: 30px;
|
||||
color: #333333;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.5px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.auth-shell__panel-subtitle.ant-typography {
|
||||
|
|
@ -180,7 +168,7 @@
|
|||
.auth-shell__panel-content .ant-input-affix-wrapper,
|
||||
.auth-shell__panel-content .ant-input,
|
||||
.auth-shell__panel-content .ant-btn {
|
||||
border-radius: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-content .ant-input-affix-wrapper,
|
||||
|
|
@ -196,7 +184,7 @@
|
|||
.auth-shell__panel-footer {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-top: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.auth-form__inline {
|
||||
|
|
@ -231,9 +219,9 @@
|
|||
.auth-form__policy-hints {
|
||||
margin: -4px 0 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(241, 245, 249, 0.78);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.auth-form__policy-hints-title {
|
||||
|
|
@ -262,6 +250,11 @@
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.auth-form__notice {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.auth-shell {
|
||||
padding: 20px;
|
||||
|
|
@ -275,6 +268,8 @@
|
|||
.auth-shell__aside {
|
||||
padding: 36px 28px 28px;
|
||||
gap: 24px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.auth-shell__main {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
/* Left Hero Section */
|
||||
.login-left {
|
||||
flex: 1.1;
|
||||
background: linear-gradient(140deg, #f0f5ff 0%, #eef5ff 40%, #f7fbff 100%);
|
||||
background: #f9fafe;
|
||||
padding: 56px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -26,14 +25,13 @@
|
|||
.brand-logo-img {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
filter: drop-shadow(0 8px 16px rgba(45, 107, 255, 0.24));
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2f3a4f;
|
||||
letter-spacing: -0.2px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
|
|
@ -42,12 +40,12 @@
|
|||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 42px;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #1d2b3a;
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: -0.5px;
|
||||
letter-spacing: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +57,7 @@
|
|||
.hero-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: #687489;
|
||||
color: #596275;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +77,6 @@
|
|||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Right Form Section */
|
||||
.login-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
|
@ -101,9 +98,9 @@
|
|||
.login-header h2 {
|
||||
font-size: 28px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #1f2a37 !important;
|
||||
color: #333 !important;
|
||||
margin-bottom: 8px !important;
|
||||
letter-spacing: -0.5px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-header span {
|
||||
|
|
@ -117,7 +114,7 @@
|
|||
|
||||
.login-form .ant-input-affix-wrapper-lg {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
|
|
@ -130,7 +127,7 @@
|
|||
width: 120px;
|
||||
height: 46px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -166,7 +163,7 @@
|
|||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
|
|
@ -174,14 +171,14 @@
|
|||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.login-left {
|
||||
padding: 48px;
|
||||
|
|
@ -201,8 +198,9 @@
|
|||
.login-container {
|
||||
background: #ffffff;
|
||||
padding: 48px 32px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export default function ResetPassword() {
|
|||
type="info"
|
||||
message="密码更新后将直接进入系统首页"
|
||||
description="新密码提交成功后立即生效,旧密码会同时失效。"
|
||||
style={{marginBottom: 20, borderRadius: 14}}
|
||||
className="auth-form__notice"
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} requiredMark={false} className="auth-form">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
.role-permission-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.role-permission-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.role-permission-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.role-permission-toolbar {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
.role-permission-layout > .ant-col {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.role-permission-layout .full-height-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.role-permission-layout .full-height-card .ant-card-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
@ -17,10 +17,11 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { ClusterOutlined, KeyOutlined, SafetyCertificateOutlined, SaveOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "@/api";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysPermission, SysRole } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -175,22 +176,26 @@ export default function RolePermissionBinding() {
|
|||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("rolePerm.title")}
|
||||
subtitle={t("rolePerm.subtitle")}
|
||||
headerExtra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined aria-hidden="true" />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!selectedRoleId || (selectedRole?.roleCode === "TENANT_ADMIN" && !isPlatformMode)}
|
||||
>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ height: "calc(100vh - 180px)" }}>
|
||||
<PageContainer title={null} className="role-permission-page">
|
||||
<SectionCard
|
||||
title={t("rolePerm.title")}
|
||||
description={t("rolePerm.subtitle")}
|
||||
>
|
||||
<div className="app-page__content-toolbar role-permission-toolbar">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined aria-hidden="true" />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!selectedRoleId || (selectedRole?.roleCode === "TENANT_ADMIN" && !isPlatformMode)}
|
||||
>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters" />
|
||||
</div>
|
||||
<Row gutter={16} className="role-permission-layout">
|
||||
<Col xs={24} lg={10} style={{ height: "100%" }}>
|
||||
<Card title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>{t("rolePerm.roleList")}</span></Space>} className="app-page__panel-card full-height-card">
|
||||
<div className="mb-4">
|
||||
|
|
@ -275,6 +280,7 @@ export default function RolePermissionBinding() {
|
|||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
.user-role-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.user-role-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.user-role-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-role-toolbar {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
.user-role-layout > .ant-col {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.user-role-layout .full-height-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user-role-layout .full-height-card .ant-card-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
@ -17,9 +17,10 @@ import { useTranslation } from "react-i18next";
|
|||
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
|
||||
import { SaveOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import type { SysRole, SysUser } from "@/types";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import "./index.less";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -101,16 +102,20 @@ export default function UserRoleBinding() {
|
|||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("userRole.title")}
|
||||
subtitle={t("userRole.subtitle")}
|
||||
headerExtra={
|
||||
<Button type="primary" icon={<SaveOutlined aria-hidden="true" />} onClick={handleSave} loading={saving} disabled={!selectedUserId}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ height: "calc(100vh - 180px)" }}>
|
||||
<PageContainer title={null} className="user-role-page">
|
||||
<SectionCard
|
||||
title={t("userRole.title")}
|
||||
description={t("userRole.subtitle")}
|
||||
>
|
||||
<div className="app-page__content-toolbar user-role-toolbar">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
<Button type="primary" icon={<SaveOutlined aria-hidden="true" />} onClick={handleSave} loading={saving} disabled={!selectedUserId}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters" />
|
||||
</div>
|
||||
<Row gutter={16} className="user-role-layout">
|
||||
<Col xs={24} lg={12} style={{ height: "100%" }}>
|
||||
<Card title={<Space><UserOutlined aria-hidden="true" /><span>{t("userRole.userList")}</span></Space>} className="app-page__panel-card full-height-card">
|
||||
<div className="mb-4">
|
||||
|
|
@ -197,6 +202,7 @@ export default function UserRoleBinding() {
|
|||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,3 +11,35 @@
|
|||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ai-models-page .section-card__tabs {
|
||||
margin-bottom: 0;
|
||||
padding: 8px 8px 0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background-color: #f9fafe;
|
||||
}
|
||||
|
||||
.ai-models-page .section-card__content {
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.ai-models-search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ai-models-data-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-models-data-panel .data-list-panel__table-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ai-models-search,
|
||||
.ai-models-data-panel .data-list-panel__right-actions .ant-input-affix-wrapper {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -475,6 +475,7 @@ const AiModels: React.FC = () => {
|
|||
}
|
||||
>
|
||||
<DataListPanel
|
||||
className="ai-models-data-panel"
|
||||
leftActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
新增模型
|
||||
|
|
@ -486,7 +487,7 @@ const AiModels: React.FC = () => {
|
|||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onPressEnter={(event) => setSearchName((event.target as HTMLInputElement).value)}
|
||||
style={{ width: 220 }}
|
||||
className="ai-models-search"
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
.client-management-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.client-management-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.client-summary-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-summary-chip {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-summary-chip .anticon {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.client-summary-chip strong {
|
||||
color: #1677ff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.client-summary-chips,
|
||||
.client-summary-chip {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
|
|
@ -13,21 +11,23 @@ import {
|
|||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, LaptopOutlined, MobileOutlined, PlusOutlined, ReloadOutlined, RocketOutlined, SearchOutlined, UploadOutlined, WindowsOutlined } from "@ant-design/icons";
|
||||
import { CloudUploadOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, LaptopOutlined, MobileOutlined, PlusOutlined, ReloadOutlined, RocketOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { createClientDownload, deleteClientDownload, listClientDownloads, type ClientDownloadDTO, type ClientDownloadVO, updateClientDownload, uploadClientPackage } from "@/api/business/client";
|
||||
import { fetchDictItemsByTypeCode } from "@/api/dict";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import type { SysDictItem } from "@/types";
|
||||
import "./ClientManagement.css";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
|
@ -406,108 +406,76 @@ export default function ClientManagement() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="客户端管理"
|
||||
subtitle="发布平台由数据字典 client_platform 驱动,并按父子分组展示。"
|
||||
headerExtra={
|
||||
<Space size={12}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading || groupLoading || platformLoading} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>发布新版</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>发布总数</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.total}</span>
|
||||
<PageContainer title={null} className="client-management-page">
|
||||
<SectionCard
|
||||
title="客户端管理"
|
||||
description="发布平台由数据字典 client_platform 驱动,并按父子分组展示。"
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
发布新版
|
||||
</Button>
|
||||
<div className="client-summary-chips" aria-label="客户端发布统计">
|
||||
<span className="client-summary-chip"><CloudUploadOutlined aria-hidden="true" /><span>发布总数</span><strong>{stats.total}</strong></span>
|
||||
<span className="client-summary-chip"><LaptopOutlined aria-hidden="true" /><span>已启用</span><strong>{stats.enabled}</strong></span>
|
||||
<span className="client-summary-chip"><MobileOutlined aria-hidden="true" /><span>最新版本</span><strong>{stats.latest}</strong></span>
|
||||
<span className="client-summary-chip"><RocketOutlined aria-hidden="true" /><span>平台分组</span><strong>{stats.groups}</strong></span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(22, 119, 255, 0.1), rgba(54, 207, 201, 0.1))', border: '1px solid rgba(22, 119, 255, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CloudUploadOutlined style={{ color: "#1677ff", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>已启用</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.enabled}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(82, 196, 26, 0.1), rgba(183, 235, 143, 0.1))', border: '1px solid rgba(82, 196, 26, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<LaptopOutlined style={{ color: "#52c41a", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>最新版本</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.latest}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(114, 46, 209, 0.1), rgba(179, 127, 235, 0.1))', border: '1px solid rgba(114, 46, 209, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MobileOutlined style={{ color: "#722ed1", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>平台分组</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.groups}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(250, 140, 22, 0.1), rgba(255, 213, 145, 0.1))', border: '1px solid rgba(250, 140, 22, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<LaptopOutlined style={{ color: "#fa8c16", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="app-page__filter-card mb-4" styles={{ body: { padding: 16 } }}>
|
||||
<Space wrap style={{width: "100%"}}>
|
||||
<Input placeholder="搜索平台、版本、系统要求或下载地址" prefix={<SearchOutlined/>} allowClear
|
||||
style={{width: 320}} value={searchValue} onChange={(event) => setSearchValue(event.target.value)}/>
|
||||
<Select style={{width: 150}} value={statusFilter}
|
||||
options={STATUS_FILTER_OPTIONS as unknown as { label: string; value: string }[]}
|
||||
onChange={(value) => setStatusFilter(value as typeof statusFilter)}/>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{width: 180}}
|
||||
value={activeTab}
|
||||
options={platformTypeOptions}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<div className="app-page__table-wrap" style={{padding: "0 24px", overflow: "hidden"}}>
|
||||
<Table
|
||||
</Space>
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索平台、版本、系统要求或下载地址"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={statusFilter}
|
||||
options={STATUS_FILTER_OPTIONS as unknown as { label: string; value: string }[]}
|
||||
onChange={(value) => setStatusFilter(value as typeof statusFilter)}
|
||||
/>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
value={activeTab}
|
||||
options={platformTypeOptions}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading || groupLoading || platformLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable<ClientDownloadVO>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={pagedRecords}
|
||||
loading={loading || groupLoading || platformLoading}
|
||||
locale={platformGroups.length === 0 ? { emptyText: <Empty description="未配置 client_platform 字典项" /> } : undefined}
|
||||
scroll={{ x: 1100, y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }}
|
||||
/>
|
||||
</Card>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer title={editing ? "编辑客户端版本" : "新增客户端版本"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={680} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>取消</Button><Button type="primary" icon={<UploadOutlined />} loading={saving} onClick={() => void handleSubmit()}>保存</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
.external-app-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.external-app-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.external-app-summary-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.external-app-summary-chip {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.external-app-summary-chip .anticon {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.external-app-summary-chip strong {
|
||||
color: #1677ff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.external-app-summary-chips,
|
||||
.external-app-summary-chip {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { App, Avatar, Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tag, Typography, Upload } from "antd";
|
||||
import { App, Avatar, Button, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Tag, Typography, Upload } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { AppstoreOutlined, DeleteOutlined, EditOutlined, GlobalOutlined, LinkOutlined, PictureOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { createExternalApp, deleteExternalApp, listExternalApps, type ExternalAppDTO, type ExternalAppVO, updateExternalApp, uploadExternalAppApk, uploadExternalAppIcon } from "@/api/business/externalApp";
|
||||
import "./ExternalAppManagement.css";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
|
@ -291,92 +294,57 @@ export default function ExternalAppManagement() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="外部应用管理"
|
||||
subtitle="统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
|
||||
headerExtra={
|
||||
<Space size={12}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>新增应用</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>应用总数</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.total}</span>
|
||||
<PageContainer title={null} className="external-app-page">
|
||||
<SectionCard
|
||||
title="外部应用管理"
|
||||
description="统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增应用
|
||||
</Button>
|
||||
<div className="external-app-summary-chips" aria-label="外部应用统计">
|
||||
<span className="external-app-summary-chip"><AppstoreOutlined aria-hidden="true" /><span>应用总数</span><strong>{stats.total}</strong></span>
|
||||
<span className="external-app-summary-chip"><RobotOutlined aria-hidden="true" /><span>原生应用</span><strong>{stats.native}</strong></span>
|
||||
<span className="external-app-summary-chip"><GlobalOutlined aria-hidden="true" /><span>Web 应用</span><strong>{stats.web}</strong></span>
|
||||
<span className="external-app-summary-chip"><PictureOutlined aria-hidden="true" /><span>已启用</span><strong>{stats.enabled}</strong></span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(22, 119, 255, 0.1), rgba(54, 207, 201, 0.1))', border: '1px solid rgba(22, 119, 255, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppstoreOutlined style={{ color: "#1677ff", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>原生应用</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.native}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(82, 196, 26, 0.1), rgba(183, 235, 143, 0.1))', border: '1px solid rgba(82, 196, 26, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<RobotOutlined style={{ color: "#52c41a", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>Web 应用</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.web}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(114, 46, 209, 0.1), rgba(179, 127, 235, 0.1))', border: '1px solid rgba(114, 46, 209, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<GlobalOutlined style={{ color: "#722ed1", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', backdropFilter: 'blur(12px)', boxShadow: '0 4px 20px rgba(0,0,0,0.02)' }} styles={{ body: { padding: '20px 24px' } }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ color: 'var(--app-text-secondary)', fontSize: 13, fontWeight: 500 }}>已启用</span>
|
||||
<span style={{ color: 'var(--app-text-main)', fontSize: 32, fontWeight: 800, fontFamily: 'system-ui, -apple-system, sans-serif' }}>{stats.enabled}</span>
|
||||
</div>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 16, background: 'linear-gradient(135deg, rgba(250, 140, 22, 0.1), rgba(255, 213, 145, 0.1))', border: '1px solid rgba(250, 140, 22, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PictureOutlined style={{ color: "#fa8c16", fontSize: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="app-page__filter-card mb-4" styles={{ body: { padding: 16 } }}>
|
||||
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space wrap>
|
||||
<Input placeholder="搜索名称、描述、包名或入口地址" prefix={<SearchOutlined />} allowClear style={{ width: 320 }} value={searchValue} onChange={(event) => setSearchValue(event.target.value)} />
|
||||
<Select value={appTypeFilter} style={{ width: 140 }} onChange={(value) => setAppTypeFilter(value)} options={[{ label: "全部类型", value: "all" }, { label: "原生应用", value: "native" }, { label: "Web 应用", value: "web" }]} />
|
||||
<Select value={statusFilter} style={{ width: 140 }} onChange={(value) => setStatusFilter(value as typeof statusFilter)} options={STATUS_OPTIONS as unknown as { label: string; value: string }[]} />
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<div className="app-page__table-wrap" style={{ padding: "0 24px", overflow: "auto" }}>
|
||||
<Table rowKey="id" columns={columns} dataSource={pagedRecords} loading={loading} scroll={{ x: "max-content", y: "100%" }} pagination={false} />
|
||||
</div>
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<Input placeholder="搜索名称、描述、包名或入口地址" prefix={<SearchOutlined />} allowClear style={{ width: 300 }} value={searchValue} onChange={(event) => setSearchValue(event.target.value)} />
|
||||
<Select value={appTypeFilter} style={{ width: 130 }} onChange={(value) => setAppTypeFilter(value)} options={[{ label: "全部类型", value: "all" }, { label: "原生应用", value: "native" }, { label: "Web 应用", value: "web" }]} />
|
||||
<Select value={statusFilter} style={{ width: 130 }} onChange={(value) => setStatusFilter(value as typeof statusFilter)} options={STATUS_OPTIONS as unknown as { label: string; value: string }[]} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable<ExternalAppVO>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={pagedRecords}
|
||||
loading={loading}
|
||||
scroll={{ x: "max-content", y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer title={editing ? "编辑外部应用" : "新增外部应用"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={700} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>取消</Button><Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSubmit()}>保存</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
.hotwords-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.hotwords-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hotwords-layout {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel.ant-card {
|
||||
width: 300px;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .ant-card-head {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .ant-card-head-title {
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .ant-card-extra .ant-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .ant-card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__filters {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__filters .ant-input-search,
|
||||
.hotwords-group-panel__filters .ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__filter-stack {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hotwords-group-panel__list .ant-list {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.hotwords-group-item.ant-list-item {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
min-height: 68px;
|
||||
padding: 10px 12px 10px 14px !important;
|
||||
border-bottom: 1px solid #f0f0f0 !important;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hotwords-group-item.ant-list-item:hover {
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.hotwords-group-item.is-selected {
|
||||
background: #f3f6ff;
|
||||
}
|
||||
|
||||
.hotwords-group-item.is-selected::before {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: #3c70f5;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.hotwords-group-item.is-selected .hotwords-group-item__title {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-meta {
|
||||
min-width: 0;
|
||||
margin-block-end: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-action {
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-action > li {
|
||||
padding-inline: 0 4px;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-action .ant-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hotwords-group-item .ant-list-item-meta-title,
|
||||
.hotwords-group-item .ant-list-item-meta-description {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hotwords-group-item__title {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hotwords-group-item__desc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.hotwords-group-item__desc .ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hotwords-group-item__desc > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .app-pagination-container {
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
min-height: 54px;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-radius: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .app-pagination-total {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .app-pagination-container .ant-pagination {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: max-content;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple .ant-pagination-prev,
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple .ant-pagination-next {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
line-height: 22px;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple .ant-pagination-simple-pager {
|
||||
height: 24px;
|
||||
margin-inline: 2px;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.hotwords-group-panel .hotwords-group-pagination.ant-pagination-simple .ant-pagination-simple-pager input {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hotwords-list-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hotwords-list-panel .data-list-panel__left-actions {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.hotwords-list-panel .data-list-panel__table-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hotwords-pinyin-tag.ant-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hotwords-search__word {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.hotwords-search__category {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.hotwords-modal-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.hotwords-weight-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hotwords-table.ant-table-wrapper,
|
||||
.hotwords-table .ant-spin-nested-loading,
|
||||
.hotwords-table .ant-spin-container,
|
||||
.hotwords-table .ant-table,
|
||||
.hotwords-table .ant-table-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hotwords-table .ant-table-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.hotwords-layout {
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hotwords-group-panel.ant-card {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hotwords-list-panel .data-list-panel__left-actions,
|
||||
.hotwords-list-panel .data-list-panel__right-actions,
|
||||
.hotwords-list-panel .data-list-panel__right-actions .ant-space,
|
||||
.hotwords-list-panel .data-list-panel__right-actions .ant-input-affix-wrapper,
|
||||
.hotwords-list-panel .data-list-panel__right-actions .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import {
|
|||
Typography,
|
||||
} from "antd";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
|
|
@ -45,6 +47,7 @@ import {
|
|||
type HotWordGroupVO,
|
||||
} from "../../api/business/hotwordGroup";
|
||||
import AppPagination from "../../components/shared/AppPagination";
|
||||
import "./HotWords.css";
|
||||
|
||||
const { Option } = Select;
|
||||
const { Text } = Typography;
|
||||
|
|
@ -136,8 +139,8 @@ const HotWords: React.FC = () => {
|
|||
tenantId: isPlatformAdmin ? activeTenantId : undefined,
|
||||
});
|
||||
if (res.data?.data) {
|
||||
setData(res.data.data.records);
|
||||
setTotal(res.data.data.total);
|
||||
setData(res.data.data.records || []);
|
||||
setTotal(res.data.data.total || 0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -312,7 +315,7 @@ const HotWords: React.FC = () => {
|
|||
title: "拼音",
|
||||
dataIndex: "pinyinList",
|
||||
key: "pinyinList",
|
||||
render: (list: string[]) => list?.[0] ? <Tag style={{ borderRadius: 4 }}>{list[0]}</Tag> : <Text type="secondary">-</Text>,
|
||||
render: (list: string[]) => list?.[0] ? <Tag className="hotwords-pinyin-tag">{list[0]}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
|
|
@ -366,177 +369,182 @@ const HotWords: React.FC = () => {
|
|||
|
||||
return (
|
||||
<PageContainer
|
||||
title="热词管理"
|
||||
subtitle="管理ASR识别引擎的热词库,提升特定场景下的识别准确率"
|
||||
title={null}
|
||||
className="hotwords-page"
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '16px', flex: 1, minHeight: 0 }}>
|
||||
{/* Left Panel: Hotword Groups */}
|
||||
<Card
|
||||
className="app-page__content-card"
|
||||
title="热词组"
|
||||
style={{ width: '20%', display: 'flex', flexDirection: 'column', minWidth: 240, maxWidth: 300 }}
|
||||
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 } }}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => openGroupEditor()}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid var(--ant-color-border-secondary, #f0f0f0)" }}>
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
<Input.Search
|
||||
placeholder="搜索热词组名称"
|
||||
allowClear
|
||||
value={groupSearchInput}
|
||||
onChange={(e) => setGroupSearchInput(e.target.value)}
|
||||
onSearch={(value) => {
|
||||
setGroupSearchInput(value);
|
||||
setGroupSearchName(value.trim());
|
||||
setGroupCurrent(1);
|
||||
}}
|
||||
<SectionCard
|
||||
title="热词管理"
|
||||
description="管理 ASR 识别热词与热词组,提升特定场景下的转写识别准确率。"
|
||||
>
|
||||
<div className="hotwords-layout">
|
||||
<Card
|
||||
className="hotwords-group-panel"
|
||||
title="热词组"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => openGroupEditor()}
|
||||
size="small"
|
||||
aria-label="新增热词组"
|
||||
/>
|
||||
<Select
|
||||
placeholder="按状态筛选"
|
||||
allowClear
|
||||
value={groupSearchStatus}
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ label: "启用", value: 1 },
|
||||
{ label: "禁用", value: 0 },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setGroupSearchStatus(value);
|
||||
setGroupCurrent(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
<List
|
||||
loading={groupLoading}
|
||||
dataSource={groupListData}
|
||||
renderItem={(item) => {
|
||||
const isSelected = searchGroupId === item.id;
|
||||
const actions = [];
|
||||
if (item.id) {
|
||||
actions.push(
|
||||
<Button
|
||||
key={`edit-${item.id}`}
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => openGroupEditor(item, e)}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
actions.push(
|
||||
<Popconfirm
|
||||
key={`delete-${item.id}`}
|
||||
title="确定删除这个热词组吗?"
|
||||
description="删除前必须先解除模板引用并清空组内热词。"
|
||||
onConfirm={(e) => handleDeleteGroup(item.id, e)}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
}
|
||||
>
|
||||
<div className="hotwords-group-panel__filters">
|
||||
<Space direction="vertical" size={12} className="hotwords-group-panel__filter-stack">
|
||||
<Input.Search
|
||||
placeholder="搜索热词组名称"
|
||||
allowClear
|
||||
value={groupSearchInput}
|
||||
onChange={(e) => setGroupSearchInput(e.target.value)}
|
||||
onSearch={(value) => {
|
||||
setGroupSearchInput(value);
|
||||
setGroupSearchName(value.trim());
|
||||
setGroupCurrent(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="按状态筛选"
|
||||
allowClear
|
||||
value={groupSearchStatus}
|
||||
options={[
|
||||
{ label: "启用", value: 1 },
|
||||
{ label: "禁用", value: 0 },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setGroupSearchStatus(value);
|
||||
setGroupCurrent(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="hotwords-group-panel__list">
|
||||
<List
|
||||
loading={groupLoading}
|
||||
dataSource={groupListData}
|
||||
renderItem={(item) => {
|
||||
const isSelected = searchGroupId === item.id;
|
||||
const actions = [];
|
||||
if (item.id) {
|
||||
actions.push(
|
||||
<Button
|
||||
key={`edit-${item.id}`}
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => openGroupEditor(item, e)}
|
||||
size="small"
|
||||
/>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
);
|
||||
actions.push(
|
||||
<Popconfirm
|
||||
key={`delete-${item.id}`}
|
||||
title="确定删除这个热词组吗?"
|
||||
description="删除前必须先解除模板引用并清空组内热词。"
|
||||
onConfirm={(e) => handleDeleteGroup(item.id, e)}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="small"
|
||||
/>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => handleSelectGroup(item)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '12px 24px',
|
||||
background: isSelected ? 'var(--ant-color-primary-bg, #e6f4ff)' : 'transparent',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
transition: 'background 0.3s'
|
||||
}}
|
||||
actions={actions}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Text strong style={{ color: isSelected ? 'var(--ant-color-primary, #1677ff)' : 'inherit' }}>
|
||||
{item.groupName}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
item.id
|
||||
? (
|
||||
<>
|
||||
<Tag color={item.hotWordCount >= 200 ? "red" : item.status === 1 ? "processing" : "default"}>
|
||||
{item.hotWordCount}/200
|
||||
</Tag>
|
||||
{item.remark}
|
||||
</>
|
||||
)
|
||||
: '查看所有热词'
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => handleSelectGroup(item)}
|
||||
className={isSelected ? "hotwords-group-item is-selected" : "hotwords-group-item"}
|
||||
actions={actions}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Text strong className="hotwords-group-item__title">
|
||||
{item.groupName}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
item.id
|
||||
? (
|
||||
<span className="hotwords-group-item__desc">
|
||||
<Tag color={item.hotWordCount >= 200 ? "red" : item.status === 1 ? "processing" : "default"}>
|
||||
{item.hotWordCount}/200
|
||||
</Tag>
|
||||
<span>{item.remark || "暂无备注"}</span>
|
||||
</span>
|
||||
)
|
||||
: "查看所有热词"
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
className="hotwords-group-pagination"
|
||||
simple
|
||||
showSizeChanger={false}
|
||||
showQuickJumper={false}
|
||||
current={groupCurrent}
|
||||
pageSize={groupSize}
|
||||
total={groupTotal}
|
||||
onChange={(page, pageSize) => {
|
||||
setGroupCurrent(page);
|
||||
setGroupSize(pageSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={groupCurrent}
|
||||
pageSize={groupSize}
|
||||
total={groupTotal}
|
||||
onChange={(page, pageSize) => {
|
||||
setGroupCurrent(page);
|
||||
setGroupSize(pageSize);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
{/* Right Panel: Hotwords */}
|
||||
<Card
|
||||
className="app-page__content-card"
|
||||
title={hotWordGroupTitle}
|
||||
extra={
|
||||
<Space wrap size="small">
|
||||
<Input
|
||||
placeholder="搜索热词原文"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
value={searchWord}
|
||||
onChange={(e) => setSearchWord(e.target.value)}
|
||||
onPressEnter={() => { setCurrent(1); void fetchData(); }}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选分类"
|
||||
allowClear
|
||||
value={searchCategory || undefined}
|
||||
onChange={(value) => {
|
||||
setSearchCategory(value as string);
|
||||
setCurrent(1);
|
||||
void fetchData();
|
||||
<DataListPanel
|
||||
className="hotwords-list-panel"
|
||||
leftActions={<Text strong>{hotWordGroupTitle}</Text>}
|
||||
rightActions={
|
||||
<Space wrap size="small">
|
||||
<Input
|
||||
placeholder="搜索热词原文"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
value={searchWord}
|
||||
onChange={(e) => setSearchWord(e.target.value)}
|
||||
onPressEnter={() => { setCurrent(1); void fetchData(); }}
|
||||
className="hotwords-search__word"
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选分类"
|
||||
allowClear
|
||||
value={searchCategory || undefined}
|
||||
onChange={(value) => {
|
||||
setSearchCategory(value as string);
|
||||
setCurrent(1);
|
||||
void fetchData();
|
||||
}}
|
||||
className="hotwords-search__category"
|
||||
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增热词
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { setCurrent(1); void fetchData(); void loadGroupPage(); }} title="刷新" aria-label="刷新" />
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setSize(pageSize);
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增热词
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { setCurrent(1); void fetchData(); void loadGroupPage(); }}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}
|
||||
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
|
||||
>
|
||||
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "16px 24px 0" }}>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className="hotwords-table"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
|
|
@ -544,18 +552,9 @@ const HotWords: React.FC = () => {
|
|||
scroll={{ x: "max-content", y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
setCurrent(page);
|
||||
setSize(pageSize);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</DataListPanel>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<Modal
|
||||
title={editingId ? "编辑热词" : "新增热词"}
|
||||
|
|
@ -566,7 +565,7 @@ const HotWords: React.FC = () => {
|
|||
width={560}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form form={form} layout="vertical" className="hotwords-modal-form">
|
||||
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: "请输入热词原文" }]}>
|
||||
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
||||
</Form.Item>
|
||||
|
|
@ -601,7 +600,7 @@ const HotWords: React.FC = () => {
|
|||
label="识别权重 (1-5)"
|
||||
tooltip="权重越高,识别引擎越倾向于将其识别为该热词"
|
||||
>
|
||||
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: "100%" }} />
|
||||
<InputNumber min={1} max={5} precision={1} step={0.1} className="hotwords-weight-input" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
|
|
@ -628,7 +627,7 @@ const HotWords: React.FC = () => {
|
|||
confirmLoading={groupSubmitLoading}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={groupForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form form={groupForm} layout="vertical" className="hotwords-modal-form">
|
||||
<Form.Item name="groupName" label="热词组名称" rules={[{ required: true, message: "请输入热词组名称" }]}>
|
||||
<Input placeholder="例如:项目术语、客户名单" maxLength={100} />
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
.license-management-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.license-management-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.license-summary-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.license-summary-chip {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.license-summary-chip .anticon {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.license-summary-chip strong {
|
||||
color: #1677ff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.license-summary-chips,
|
||||
.license-summary-chip {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { App, Button, Card, Col, Empty, Input, Row, Space, Table, Tag, Typography, Upload } from "antd";
|
||||
import { App, Button, Input, Space, Tag, Typography, Upload } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { CheckCircleOutlined, ClockCircleOutlined, KeyOutlined, LinkOutlined, ReloadOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { importLicenses, listLicenses, type LicenseImportResultVO, type LicenseVO } from "@/api/business/license";
|
||||
import "./LicenseManagement.css";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -143,101 +147,78 @@ export default function LicenseManagement() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="授权码管理"
|
||||
subtitle="查看当前租户授权池"
|
||||
headerExtra={
|
||||
<Space size={12}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
{/*<Upload showUploadList={false} beforeUpload={(file) => { void handleImport(file as File); return Upload.LIST_IGNORE; }}>*/}
|
||||
{/* <Button type="primary" icon={<UploadOutlined />} loading={uploading}>*/}
|
||||
{/* 导入正式授权*/}
|
||||
{/* </Button>*/}
|
||||
{/*</Upload>*/}
|
||||
</Space>
|
||||
}
|
||||
toolbar={
|
||||
<Input
|
||||
placeholder="搜索序列号、授权码、设备编码"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 360 }}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16 }}>
|
||||
<Space size={16}>
|
||||
<KeyOutlined style={{ color: "#1677ff", fontSize: 24 }} />
|
||||
<div>
|
||||
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}>授权总数</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.total}</div>
|
||||
</div>
|
||||
<PageContainer title={null} className="license-management-page">
|
||||
<SectionCard
|
||||
title="授权码管理"
|
||||
description="查看当前租户授权池"
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
<div className="license-summary-chips" aria-label="授权码统计">
|
||||
<span className="license-summary-chip">
|
||||
<KeyOutlined aria-hidden="true" />
|
||||
<span>授权总数</span>
|
||||
<strong className="tabular-nums">{stats.total}</strong>
|
||||
</span>
|
||||
<span className="license-summary-chip">
|
||||
<LinkOutlined aria-hidden="true" />
|
||||
<span>使用中</span>
|
||||
<strong className="tabular-nums">{stats.using}</strong>
|
||||
</span>
|
||||
<span className="license-summary-chip">
|
||||
<CheckCircleOutlined aria-hidden="true" />
|
||||
<span>正式授权</span>
|
||||
<strong className="tabular-nums">{stats.formal}</strong>
|
||||
</span>
|
||||
<span className="license-summary-chip">
|
||||
<ClockCircleOutlined aria-hidden="true" />
|
||||
<span>可分配</span>
|
||||
<strong className="tabular-nums">{stats.available}</strong>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索序列号、授权码、设备编码"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 320 }}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
{/*<Upload showUploadList={false} beforeUpload={(file) => { void handleImport(file as File); return Upload.LIST_IGNORE; }}>*/}
|
||||
{/* <Button type="primary" icon={<UploadOutlined />} loading={uploading}>*/}
|
||||
{/* 导入正式授权*/}
|
||||
{/* </Button>*/}
|
||||
{/*</Upload>*/}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16 }}>
|
||||
<Space size={16}>
|
||||
<LinkOutlined style={{ color: "#52c41a", fontSize: 24 }} />
|
||||
<div>
|
||||
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}>使用中</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.using}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16 }}>
|
||||
<Space size={16}>
|
||||
<CheckCircleOutlined style={{ color: "#722ed1", fontSize: 24 }} />
|
||||
<div>
|
||||
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}>正式授权</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.formal}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16 }}>
|
||||
<Space size={16}>
|
||||
<ClockCircleOutlined style={{ color: "#fa8c16", fontSize: 24 }} />
|
||||
<div>
|
||||
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}>可分配</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.available}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<div className="app-page__table-wrap flex-1 min-h-0" style={{ padding: "0 24px", overflow: "auto" }}>
|
||||
<Table
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable<LicenseVO>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={pagedRecords}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1000, y: "100%" }}
|
||||
locale={{ emptyText: <Empty description="暂无授权数据" /> }}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,450 @@
|
|||
.meeting-detail-page-v2 {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.meeting-detail-section-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meeting-detail-section-card .section-card__title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meeting-detail-section-title {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meeting-detail-section-title > .anticon {
|
||||
flex: 0 0 auto;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-title-edit {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #f3f6ff;
|
||||
color: #3c70f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-meta-row {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
color: #9095a1;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.meeting-detail-section-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-workspace {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .detail-side-column {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .left-flow-card.ant-card {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .left-flow-card + .section-divider-note,
|
||||
.meeting-detail-page-v2 .section-divider-note + .transcript-player-anchor {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-panel {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-workspace-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs button {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs button + button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs button.active {
|
||||
border-color: #3c70f5;
|
||||
background: #f3f6ff;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-scroll-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-player--floating {
|
||||
z-index: 20;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-player {
|
||||
padding: 12px 14px !important;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .player-main-btn {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
border: 1px solid #3c70f5 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #3c70f5 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .player-ghost-btn {
|
||||
height: 36px !important;
|
||||
border: 1px solid #d8e3ff !important;
|
||||
border-radius: 4px !important;
|
||||
background: #f3f7ff !important;
|
||||
color: #3c70f5 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .player-range {
|
||||
height: 6px !important;
|
||||
border-radius: 4px !important;
|
||||
background: #e8edf7 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .player-range::-webkit-slider-thumb,
|
||||
.meeting-detail-page-v2 .player-range::-moz-range-thumb {
|
||||
border-color: #3c70f5 !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .highlight-text {
|
||||
display: inline !important;
|
||||
padding: 1px 3px !important;
|
||||
border-radius: 2px !important;
|
||||
border-bottom: 1px solid #3c70f5 !important;
|
||||
background: #f3f7ff !important;
|
||||
color: #2f5edb !important;
|
||||
font-weight: 600 !important;
|
||||
box-shadow: none !important;
|
||||
animation: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .highlight-text:hover,
|
||||
.meeting-detail-page-v2 .summary-keyword-link:hover {
|
||||
background: #eef4ff !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-keyword-link,
|
||||
.meeting-detail-page-v2 .summary-link,
|
||||
.meeting-detail-page-v2 .summary-head-link:hover,
|
||||
.meeting-detail-page-v2 .catalog-item-link,
|
||||
.meeting-detail-page-v2 .selectable-tag:hover,
|
||||
.meeting-detail-page-v2 .selectable-tag.selected,
|
||||
.meeting-detail-page-v2 .selectable-tag.highlighted-tag,
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs button:hover,
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs button.active {
|
||||
color: #3c70f5 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-title-icon,
|
||||
.meeting-detail-page-v2 .role-detail-icon,
|
||||
.meeting-detail-page-v2 .catalog-timeline-dot,
|
||||
.meeting-detail-page-v2 .chapter-time::after,
|
||||
.meeting-detail-page-v2 .discussion-marker {
|
||||
background: #3c70f5 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-title-icon,
|
||||
.meeting-detail-page-v2 .role-detail-icon {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-page-header,
|
||||
.meeting-detail-page-v2 .left-flow-card,
|
||||
.meeting-detail-page-v2 .summary-content-box,
|
||||
.meeting-detail-page-v2 .keyword-panel,
|
||||
.meeting-detail-page-v2 .transcript-workspace-card,
|
||||
.meeting-detail-page-v2 .empty-transcript-hero,
|
||||
.meeting-detail-page-v2 .empty-transcript-inline-note,
|
||||
.meeting-detail-page-v2 .empty-transcript-player,
|
||||
.meeting-detail-page-v2 .chapter-card,
|
||||
.meeting-detail-page-v2 .speaker-summary-card,
|
||||
.meeting-detail-page-v2 .keypoint-card,
|
||||
.meeting-detail-page-v2 .catalog-item-card {
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-content-box,
|
||||
.meeting-detail-page-v2 .transcript-keyword-bar,
|
||||
.meeting-detail-page-v2 .empty-transcript-inline-note {
|
||||
background: #f9fafe !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-title-text {
|
||||
color: #333333 !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 28px !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-title-edit {
|
||||
border-radius: 4px !important;
|
||||
background: #f3f7ff !important;
|
||||
color: #3c70f5 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-title,
|
||||
.meeting-detail-page-v2 .keyword-panel-title,
|
||||
.meeting-detail-page-v2 .brief-section-title,
|
||||
.meeting-detail-page-v2 .transcript-keyword-label,
|
||||
.meeting-detail-page-v2 .empty-transcript-hero__title {
|
||||
color: #333333 !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-section-title,
|
||||
.meeting-detail-page-v2 .summary-copy,
|
||||
.meeting-detail-page-v2 .discussion-body,
|
||||
.meeting-detail-page-v2 .discussion-copy,
|
||||
.meeting-detail-page-v2 .empty-transcript-hero__description,
|
||||
.meeting-detail-page-v2 .empty-transcript-inline-note__text {
|
||||
color: #606775 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-markdown li::marker {
|
||||
color: #3c70f5 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .tag,
|
||||
.meeting-detail-page-v2 .summary-tag,
|
||||
.meeting-detail-page-v2 .selectable-tag,
|
||||
.meeting-detail-page-v2 .empty-transcript-hero__badge {
|
||||
border: 1px solid #d8e3ff !important;
|
||||
border-radius: 4px !important;
|
||||
background: #f3f7ff !important;
|
||||
color: #2f5edb !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .selectable-tag.selected,
|
||||
.meeting-detail-page-v2 .selectable-tag.highlighted-tag {
|
||||
border-color: #9cb8ff !important;
|
||||
background: #eef4ff !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs {
|
||||
gap: 8px !important;
|
||||
min-height: 48px !important;
|
||||
padding: 8px 12px !important;
|
||||
border-bottom: 1px solid #e6e6e6 !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs button {
|
||||
height: 32px !important;
|
||||
padding: 0 14px !important;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
color: #333333 !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs button.active {
|
||||
border-color: #3c70f5 !important;
|
||||
background: #f3f7ff !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .transcript-stage-tabs button.active::after,
|
||||
.meeting-detail-page-v2 .segmented-tabs button.active::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .catalog-item-card:hover,
|
||||
.meeting-detail-page-v2 .catalog-item-container.active .catalog-item-card {
|
||||
border-color: #9cb8ff !important;
|
||||
background: #f9fbff !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .catalog-item-link {
|
||||
border: 1px solid #d8e3ff !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
color: #3c70f5 !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-fade::after {
|
||||
background: linear-gradient(180deg, rgba(249, 250, 254, 0), #f9fafe) !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-inline-edit {
|
||||
border-radius: 4px !important;
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
color: #333333 !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .summary-inline-edit:focus {
|
||||
border-color: #3c70f5 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.meeting-share-popover .ant-popover-inner,
|
||||
.meeting-share-popover .meeting-share-settings,
|
||||
.meeting-share-popover .meeting-share-link-box,
|
||||
.meeting-share-popover .meeting-share-qr-wrap {
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.meeting-share-popover .meeting-share-card {
|
||||
color: #333333 !important;
|
||||
background: #ffffff !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.meeting-share-popover .meeting-share-title,
|
||||
.meeting-share-popover .meeting-share-settings-copy strong {
|
||||
color: #333333 !important;
|
||||
font-family: inherit !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
.meeting-share-popover .meeting-share-kicker,
|
||||
.meeting-share-popover .meeting-share-caption,
|
||||
.meeting-share-popover .meeting-share-settings-copy span {
|
||||
color: #606775 !important;
|
||||
}
|
||||
|
||||
.meeting-share-popover .meeting-share-pill {
|
||||
border: 1px solid #d8e3ff !important;
|
||||
border-radius: 4px !important;
|
||||
color: #3c70f5 !important;
|
||||
background: #f3f7ff !important;
|
||||
}
|
||||
|
||||
.meeting-share-popover .meeting-share-qr-wrap,
|
||||
.meeting-share-popover .meeting-share-settings,
|
||||
.meeting-share-popover .meeting-share-link-box {
|
||||
background: #f9fafe !important;
|
||||
}
|
||||
|
||||
.meeting-share-popover .meeting-share-kicker {
|
||||
letter-spacing: 0 !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.meeting-detail-section-content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-workspace {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .detail-side-column {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.meeting-detail-page-v2 .section-card__extra,
|
||||
.meeting-detail-page-v2 .section-card__extra .ant-space {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.meeting-detail-page-v2 .meeting-detail-title-text {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
|
@ -56,8 +56,9 @@ import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
|||
import { listUsers } from '../../api';
|
||||
import { useDict } from '../../hooks/useDict';
|
||||
import { SysUser } from '../../types';
|
||||
import PageHeader from '../../components/shared/PageHeader';
|
||||
import PageContainer from "../../components/shared/PageContainer";
|
||||
import SectionCard from "../../components/shared/SectionCard";
|
||||
import "./MeetingDetail.css";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
|
@ -1677,8 +1678,8 @@ const MeetingDetail: React.FC = () => {
|
|||
getPromptPage({ current: 1, size: 100 }),
|
||||
getAiModelDefault('LLM'),
|
||||
]);
|
||||
setLlmModels(modelRes.data.data.records.filter((item) => item.status === 1));
|
||||
setPrompts(promptRes.data.data.records.filter((item) => item.status === 1));
|
||||
setLlmModels((modelRes.data?.data?.records || []).filter((item) => item.status === 1));
|
||||
setPrompts((promptRes.data?.data?.records || []).filter((item) => item.status === 1));
|
||||
summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id });
|
||||
} catch {
|
||||
// ignore
|
||||
|
|
@ -2271,6 +2272,7 @@ const MeetingDetail: React.FC = () => {
|
|||
await updateMeetingBasic({
|
||||
meetingId: meeting.id,
|
||||
accessPassword: sharePasswordEnabled ? normalizedPassword : '',
|
||||
summaryModelId: summaryForm.getFieldValue('summaryModelId') ?? meeting.summaryModelId,
|
||||
});
|
||||
const nextPassword = sharePasswordEnabled ? normalizedPassword : '';
|
||||
setMeeting((current) => (current ? { ...current, accessPassword: nextPassword } : current));
|
||||
|
|
@ -2371,31 +2373,28 @@ const MeetingDetail: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="meeting-detail-page">
|
||||
<PageHeader
|
||||
className="meeting-detail-page-header"
|
||||
<PageContainer title={null} className="meeting-detail-page-v2">
|
||||
<SectionCard
|
||||
className="meeting-detail-section-card"
|
||||
contentClassName="meeting-detail-section-content"
|
||||
title={(
|
||||
<div className="meeting-detail-title-wrap">
|
||||
<div className="meeting-detail-title-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div className="meeting-detail-title-copy">
|
||||
<div className="meeting-detail-title-row">
|
||||
<span className="meeting-detail-title-text">{meeting.title}</span>
|
||||
{isOwner && (
|
||||
<button type="button" className="meeting-detail-title-edit" onClick={handleEditMeeting} aria-label="编辑会议信息">
|
||||
<EditOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="meeting-detail-meta-row">
|
||||
<span>
|
||||
<ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}
|
||||
</span>
|
||||
<span>{meeting.participants || '未指定'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="meeting-detail-section-title">
|
||||
<FileTextOutlined />
|
||||
<span className="meeting-detail-title-text">{meeting.title}</span>
|
||||
{isOwner && (
|
||||
<button type="button" className="meeting-detail-title-edit" onClick={handleEditMeeting} aria-label="编辑会议信息">
|
||||
<EditOutlined />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
description={(
|
||||
<span className="meeting-detail-meta-row">
|
||||
<span>
|
||||
<ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}
|
||||
</span>
|
||||
<span>{meeting.participants || '未指定'}</span>
|
||||
</span>
|
||||
)}
|
||||
extra={(
|
||||
<Space size={10} wrap>
|
||||
|
|
@ -2490,9 +2489,8 @@ const MeetingDetail: React.FC = () => {
|
|||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="meeting-detail-workspace">
|
||||
>
|
||||
<div className="meeting-detail-workspace">
|
||||
{meeting.status === 0 || meeting.status === 1 ? (
|
||||
<>
|
||||
<div style={{ display: 'none' }}>
|
||||
|
|
@ -2865,7 +2863,8 @@ const MeetingDetail: React.FC = () => {
|
|||
</Row>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{playbackAudioUrl && showFloatingTranscriptPlayer && floatingTranscriptPlayerLayout && (
|
||||
<div
|
||||
|
|
@ -4338,7 +4337,7 @@ const MeetingDetail: React.FC = () => {
|
|||
</Form>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ function getPointsTypeLabel(value?: string) {
|
|||
|
||||
function getPointsTypeColor(value?: string) {
|
||||
if (value === "ASR") return "blue";
|
||||
if (value === "LLM") return "purple";
|
||||
if (value === "LLM") return "geekblue";
|
||||
if (value === "TRANSFER_IN") return "green";
|
||||
if (value === "TRANSFER_OUT") return "orange";
|
||||
return "default";
|
||||
|
|
@ -321,7 +321,7 @@ export default function MeetingPointsManagement() {
|
|||
title: "账户类型",
|
||||
key: "accountType",
|
||||
width: 120,
|
||||
render: () => <Tag color="purple">个人账户</Tag>,
|
||||
render: () => <Tag color="blue">个人账户</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -611,12 +611,6 @@ export default function MeetingPreview() {
|
|||
if (passwordRequired && !passwordVerified) {
|
||||
return (
|
||||
<div className="meeting-preview-page is-password-gate">
|
||||
<div className="password-gate-background">
|
||||
<div className="bg-blob bg-blob-1" />
|
||||
<div className="bg-blob bg-blob-2" />
|
||||
<div className="bg-blob bg-blob-3" />
|
||||
</div>
|
||||
|
||||
<div className="meeting-preview-shell">
|
||||
<div className="meeting-preview-password-card">
|
||||
<div className="password-card-header">
|
||||
|
|
@ -635,7 +629,7 @@ export default function MeetingPreview() {
|
|||
placeholder={TEXT.passwordPlaceholder}
|
||||
onChange={(event) => setAccessPassword(event.target.value)}
|
||||
onPressEnter={handlePasswordSubmit}
|
||||
prefix={<LockOutlined style={{ color: "var(--text-secondary)" }} />}
|
||||
prefix={<LockOutlined className="meeting-preview-muted-icon" />}
|
||||
className="modern-password-input"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -790,7 +784,7 @@ export default function MeetingPreview() {
|
|||
<h2 className="meeting-preview-section-title">{TEXT.transcriptTitle}</h2>
|
||||
</div>
|
||||
<div className="meeting-preview-section-extra">
|
||||
<ClockCircleOutlined style={{ marginRight: 6 }} />
|
||||
<ClockCircleOutlined className="meeting-preview-inline-icon" />
|
||||
{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -904,28 +898,28 @@ export default function MeetingPreview() {
|
|||
<div className="metric-item">
|
||||
<div className="metric-label">{TEXT.meetingTime}</div>
|
||||
<div className="metric-value">
|
||||
<CalendarOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
|
||||
<CalendarOutlined className="metric-value__icon" />
|
||||
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : TEXT.notSet}
|
||||
</div>
|
||||
</div>
|
||||
<div className="metric-item">
|
||||
<div className="metric-label">{TEXT.hostCreator}</div>
|
||||
<div className="metric-value">
|
||||
<UserOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
|
||||
<UserOutlined className="metric-value__icon" />
|
||||
{meeting.creatorName || TEXT.notSet}
|
||||
</div>
|
||||
</div>
|
||||
<div className="metric-item">
|
||||
<div className="metric-label">{TEXT.participantsCount}</div>
|
||||
<div className="metric-value">
|
||||
<TeamOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
|
||||
<TeamOutlined className="metric-value__icon" />
|
||||
{participantCountValue} {TEXT.participants}
|
||||
</div>
|
||||
</div>
|
||||
<div className="metric-item">
|
||||
<div className="metric-label">会议时长</div>
|
||||
<div className="metric-value">
|
||||
<ClockCircleOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
|
||||
<ClockCircleOutlined className="metric-value__icon" />
|
||||
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : TEXT.notSet}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,507 @@
|
|||
.meetings-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.meetings-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.meetings-list-panel .data-list-panel__table-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meetings-list-panel--cards .data-list-panel__table-area .app-page__table-wrap {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meetings-list-toolbar {
|
||||
align-items: stretch;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.meetings-list-toolbar .data-list-panel__left-actions {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meetings-list-toolbar .data-list-panel__right-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.meetings-toolbar-stack {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meetings-toolbar-primary {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meetings-primary-actions {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meetings-display-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.meetings-filter-actions {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.meetings-scope-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.meetings-scope-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.meetings-card-scroll {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.meetings-card-scroll .ant-list {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.meetings-card-scroll .ant-list .ant-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.meetings-card-scroll .ant-list .ant-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.meeting-card-list-item.ant-list-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.meeting-card-v2.ant-card {
|
||||
width: 100%;
|
||||
height: 164px;
|
||||
min-height: 164px;
|
||||
max-height: 164px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #fff !important;
|
||||
box-shadow: none !important;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.meeting-card-v2.ant-card:hover {
|
||||
border-color: #b7cdfd !important;
|
||||
background: #f9fafe !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.meeting-card-v2 .ant-card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 14px !important;
|
||||
}
|
||||
|
||||
.meeting-status-tag {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--meeting-status-border);
|
||||
border-radius: 4px;
|
||||
background: var(--meeting-status-bg);
|
||||
color: var(--meeting-status-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-status-tag__progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: var(--meeting-status-color);
|
||||
transition: width 0.4s ease-out;
|
||||
}
|
||||
|
||||
.meeting-status-tag__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meeting-status-tag__spin-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.meeting-status-tag__percent {
|
||||
flex-shrink: 0;
|
||||
color: var(--meeting-status-color);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meeting-status-tag--card,
|
||||
.meeting-status-tag--table {
|
||||
width: 155px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__top,
|
||||
.meeting-card-v2__footer,
|
||||
.meeting-card-v2__status-inner,
|
||||
.meeting-card-v2__meta,
|
||||
.meeting-card-v2__meta-item,
|
||||
.meeting-card-v2__tags,
|
||||
.meeting-card-v2__owner,
|
||||
.meeting-card-v2__top-actions,
|
||||
.meeting-card-v2__source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meeting-card-v2__top {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__top-actions {
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__source {
|
||||
max-width: 98px;
|
||||
flex-shrink: 0;
|
||||
gap: 5px;
|
||||
padding: 1px 7px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
color: var(--meeting-source-color);
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meeting-card-v2__source-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--meeting-source-color);
|
||||
}
|
||||
|
||||
.meeting-card-v2__title-row {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__title.ant-typography {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin: 0 !important;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.meeting-card-v2__actions .ant-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.meeting-card-v2__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__status-note {
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__status-inner {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__status-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--meeting-progress-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meeting-card-v2__status-text span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-card-v2__retry.ant-btn {
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
padding-inline: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
color: #9095a1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__meta-item {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__meta-item .anticon {
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__footer {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.meeting-card-v2__tags {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meeting-card-v2__tag {
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
padding: 1px 7px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #596275;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-card-v2__tag-empty {
|
||||
color: #bfbfbf;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__owner {
|
||||
max-width: 112px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
color: #596275;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meeting-card-v2__owner .anticon {
|
||||
flex-shrink: 0;
|
||||
color: #9095a1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meeting-card-v2__owner span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meetings-filter-count.ant-tag {
|
||||
margin: 0;
|
||||
padding-inline: 8px;
|
||||
border-radius: 4px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.meetings-status-filter {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.meetings-status-filter__suffix {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.meetings-search-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.meetings-status-filter-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.meetings-list-table.ant-table-wrapper,
|
||||
.meetings-list-table.ant-table-wrapper .ant-spin-nested-loading,
|
||||
.meetings-list-table.ant-table-wrapper .ant-spin-container,
|
||||
.meetings-list-table.ant-table-wrapper .ant-table,
|
||||
.meetings-list-table.ant-table-wrapper .ant-table-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.meetings-list-table.ant-table-wrapper .ant-table-body {
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.meetings-list-table .ant-table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meetings-table__title-link {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meetings-table__source-tag.ant-tag {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.meetings-table__participants {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.meetings-page .status-bar-active {
|
||||
animation: statusBreathing 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.meetings-page .ant-btn-primary:not(.ant-btn-dangerous) {
|
||||
border-color: #3c70f5;
|
||||
background: #3c70f5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.meetings-page .ant-btn-primary:not(.ant-btn-dangerous):hover {
|
||||
border-color: #2458d9;
|
||||
background: #2458d9;
|
||||
}
|
||||
|
||||
@keyframes statusBreathing {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.meetings-toolbar-primary {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meetings-primary-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meetings-display-switch,
|
||||
.meetings-scope-switch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meetings-display-switch .ant-radio-button-wrapper,
|
||||
.meetings-scope-switch .ant-radio-button-wrapper {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.meetings-display-switch,
|
||||
.meetings-scope-switch {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.meetings-filter-actions,
|
||||
.meetings-filter-actions .ant-input-search,
|
||||
.meetings-filter-actions .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.meeting-card-v2 .ant-card-body {
|
||||
padding: 14px !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -59,9 +59,12 @@ import {
|
|||
} from "../../api/business/meeting";
|
||||
import { MeetingCreateDrawer, type MeetingCreateType } from "../../components/business/MeetingCreateDrawer";
|
||||
import AppPagination from "../../components/shared/AppPagination";
|
||||
import DataListPanel from "../../components/shared/DataListPanel";
|
||||
import SectionCard from "../../components/shared/SectionCard";
|
||||
import { usePermission } from "../../hooks/usePermission";
|
||||
import type { SysUser } from "../../types";
|
||||
import PageContainer from "../../components/shared/PageContainer";
|
||||
import "./Meetings.css";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
|
@ -211,7 +214,7 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
|
|||
return { ...item, realtimeSessionStatus: sessionStatus.status };
|
||||
};
|
||||
|
||||
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null; style?: React.CSSProperties }> = ({ meeting, progress, style }) => {
|
||||
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null; className?: string; style?: React.CSSProperties }> = ({ meeting, progress, className, style }) => {
|
||||
const effectiveStatus = getEffectiveStatus(meeting, progress);
|
||||
const statusConfig: Record<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = {
|
||||
0: { text: "数据初始化", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> },
|
||||
|
|
@ -220,7 +223,7 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
|
|||
3: { text: "处理完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: <CheckOutlined /> },
|
||||
4: { text: "处理失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: <InfoCircleOutlined /> },
|
||||
5: { text: "暂停中", color: "#d48806", bgColor: "rgba(212, 136, 6, 0.1)", icon: <PauseCircleOutlined /> },
|
||||
6: { text: "进行中", color: "#5f51ff", bgColor: "rgba(95, 81, 255, 0.1)", icon: <SyncOutlined spin /> },
|
||||
6: { text: "进行中", color: "#3c70f5", bgColor: "#eef4ff", icon: <SyncOutlined spin /> },
|
||||
7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> },
|
||||
8: { text: "待上传录音文件", color: "#13a8a8", bgColor: "rgba(19, 168, 168, 0.1)", icon: <CloudUploadOutlined /> },
|
||||
};
|
||||
|
|
@ -231,49 +234,44 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
|
|||
const isProcessing = shouldTrackGenerationProgress(meeting) && !isUnifiedTerminalProgress(progress);
|
||||
const percent = isProcessing ? progress?.percent || 0 : 0;
|
||||
|
||||
const statusStyle = {
|
||||
...style,
|
||||
"--meeting-status-color": displayConfig.color,
|
||||
"--meeting-status-bg": displayConfig.bgColor,
|
||||
"--meeting-status-border": `${displayConfig.color}33`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 12px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 700,
|
||||
color: displayConfig.color,
|
||||
background: displayConfig.bgColor,
|
||||
border: `1px solid ${displayConfig.color}20`,
|
||||
gap: "4px",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<div className={["meeting-status-tag", isProcessing ? "is-processing" : "", className].filter(Boolean).join(" ")} style={statusStyle}>
|
||||
{isProcessing && percent > 0 && (
|
||||
<div
|
||||
className="meeting-status-tag__progress"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
height: "2px",
|
||||
width: `${percent}%`,
|
||||
background: displayConfig.color,
|
||||
transition: "width 0.4s ease-out",
|
||||
boxShadow: `0 0 8px ${displayConfig.color}`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ display: "flex", alignItems: "center" }}>
|
||||
{isProcessing ? <SyncOutlined spin style={{ fontSize: 11 }} /> : displayConfig.icon}
|
||||
<span className="meeting-status-tag__icon">
|
||||
{isProcessing ? <SyncOutlined spin className="meeting-status-tag__spin-icon" /> : displayConfig.icon}
|
||||
</span>
|
||||
<span>{displayConfig.text}</span>
|
||||
{isProcessing && <span style={{ opacity: 0.8, fontSize: "11px", fontWeight: 500 }}>{percent}%</span>}
|
||||
{isProcessing && <span className="meeting-status-tag__percent">{percent}%</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
|
||||
return <IntegratedStatusTag meeting={meeting} progress={progress} style={{ width: 155 }} />;
|
||||
return <IntegratedStatusTag meeting={meeting} progress={progress} className="meeting-status-tag--table" />;
|
||||
};
|
||||
|
||||
const getMeetingTagList = (tags: unknown) => {
|
||||
if (Array.isArray(tags)) {
|
||||
return tags.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof tags === "string") {
|
||||
return tags.split(",").map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const MeetingCardItem: React.FC<{
|
||||
|
|
@ -295,6 +293,7 @@ const MeetingCardItem: React.FC<{
|
|||
const crossPlatformHint = `在 ${getRealtimeSourceLabel(item)} 继续`;
|
||||
const canRetry = canRetryQueuedMeeting(item, progress);
|
||||
const ownerName = item.creatorName || "未知";
|
||||
const tags = getMeetingTagList(item.tags).slice(0, 2);
|
||||
const processingMessage = isWaitingUpload
|
||||
? (progress?.message || progress?.unifiedStatus?.message || config.text)
|
||||
: (progress?.unifiedStatus?.message || progress?.message || "深度分析中...");
|
||||
|
|
@ -302,90 +301,58 @@ const MeetingCardItem: React.FC<{
|
|||
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
|
||||
|
||||
return (
|
||||
<List.Item style={{ padding: 0 }}>
|
||||
<List.Item className="meeting-card-list-item">
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => onOpenMeeting(item)}
|
||||
className="meeting-card-v2"
|
||||
style={{
|
||||
borderRadius: "20px",
|
||||
border: "1px solid rgba(0, 0, 0, 0.04)",
|
||||
background: "#ffffff",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1)",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.02), 0 1px 2px rgba(0, 0, 0, 0.02)",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "200px"
|
||||
}}
|
||||
styles={{ body: { padding: "20px 24px", flex: 1, display: "flex", flexDirection: "column" } }}
|
||||
>
|
||||
{/* Top Section: Status & Source */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
|
||||
<IntegratedStatusTag meeting={item} progress={progress} style={{ width: 155 }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "2px 8px",
|
||||
background: `${sourceColor}08`,
|
||||
borderRadius: "6px",
|
||||
color: sourceColor,
|
||||
fontSize: "11px",
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.01em"
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "5px", height: "5px", borderRadius: "50%", background: sourceColor }} />
|
||||
{getMeetingSourceLabel(item.meetingSource)}
|
||||
<div className="meeting-card-v2__top">
|
||||
<IntegratedStatusTag meeting={item} progress={progress} className="meeting-status-tag--card" />
|
||||
<div className="meeting-card-v2__top-actions">
|
||||
<div
|
||||
className="meeting-card-v2__source"
|
||||
style={{ "--meeting-source-color": sourceColor } as React.CSSProperties}
|
||||
>
|
||||
<span className="meeting-card-v2__source-dot" />
|
||||
{getMeetingSourceLabel(item.meetingSource)}
|
||||
</div>
|
||||
{canManageMeeting(item) && (
|
||||
<div
|
||||
className="meeting-card-v2__actions"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Popconfirm
|
||||
title="确定删除会议吗?"
|
||||
description="删除后将无法找回该会议记录。"
|
||||
onConfirm={() => onDelete(item.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} title="删除会议" aria-label="删除会议" />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Section: Title & Actions */}
|
||||
<div style={{ position: "relative", marginBottom: 12 }}>
|
||||
<Title level={4} style={{ margin: 0, paddingRight: "32px", fontSize: "16px", lineHeight: 1.4, fontWeight: 700, color: "#1a1a1a" }} ellipsis={{ tooltip: item.title }}>
|
||||
<div className="meeting-card-v2__title-row">
|
||||
<Title level={4} className="meeting-card-v2__title" ellipsis={{ tooltip: item.title }}>
|
||||
{item.title}
|
||||
</Title>
|
||||
|
||||
{canManageMeeting(item) && (
|
||||
<div
|
||||
className="card-actions-v2"
|
||||
style={{ position: "absolute", right: -8, top: -4 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Popconfirm
|
||||
title="确定删除会议吗?"
|
||||
description="删除后将无法找回该会议记录。"
|
||||
onConfirm={() => onDelete(item.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="text" size="small" shape="circle" icon={<DeleteOutlined style={{ color: "#ff4d4f", fontSize: 13 }} />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Section: Progress or Metadata */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
<div className="meeting-card-v2__body">
|
||||
{(isProcessing || isPaused || isRealtimeActive || isRealtimeIdle) ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
borderRadius: "10px",
|
||||
background: "var(--app-bg-surface-strong)",
|
||||
border: "1px solid rgba(0,0,0,0.02)",
|
||||
fontSize: "12px"
|
||||
}}
|
||||
className="meeting-card-v2__status-note"
|
||||
style={{ "--meeting-progress-color": config.color } as React.CSSProperties}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "10px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", minWidth: 0, color: config.color, fontWeight: 600 }}>
|
||||
<div className="meeting-card-v2__status-inner">
|
||||
<div className="meeting-card-v2__status-text">
|
||||
{isProcessing ? <SyncOutlined spin /> : <InfoCircleOutlined />}
|
||||
<span style={{ fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
<span>
|
||||
{isProcessing ? processingMessage : (isCrossPlatformRealtime ? crossPlatformHint : config.text)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -398,7 +365,7 @@ const MeetingCardItem: React.FC<{
|
|||
event.stopPropagation();
|
||||
onRetrySchedule(item);
|
||||
}}
|
||||
style={{ paddingInline: 0, height: 18, lineHeight: "18px", flexShrink: 0, fontSize: "12px", fontWeight: 600 }}
|
||||
className="meeting-card-v2__retry"
|
||||
>
|
||||
重新调度
|
||||
</Button>
|
||||
|
|
@ -406,62 +373,33 @@ const MeetingCardItem: React.FC<{
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", color: "#8c8c8c", fontSize: "12px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<CalendarOutlined style={{ fontSize: "13px", opacity: 0.7 }} />
|
||||
<div className="meeting-card-v2__meta">
|
||||
<div className="meeting-card-v2__meta-item">
|
||||
<CalendarOutlined />
|
||||
<span>{dayjs(item.meetingTime).format("MM-DD HH:mm")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Tags & Navigate */}
|
||||
<div style={{ marginTop: "auto", paddingTop: 12, borderTop: "1px solid rgba(0,0,0,0.04)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", gap: "4px", flex: 1, overflow: "hidden" }}>
|
||||
{item.tags?.split(",").filter(Boolean).slice(0, 2).map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
padding: "1px 8px",
|
||||
borderRadius: "4px",
|
||||
background: "#f3f4f6",
|
||||
color: "#6b7280",
|
||||
fontSize: "11px",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "90px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
)) || <span style={{ fontSize: "11px", color: "#bfbfbf" }}>无标签</span>}
|
||||
<div className="meeting-card-v2__footer">
|
||||
<div className="meeting-card-v2__tags">
|
||||
{tags.length > 0 ? (
|
||||
tags.map(tag => (
|
||||
<span key={tag} className="meeting-card-v2__tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="meeting-card-v2__tag-empty">无标签</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: "28px",
|
||||
maxWidth: "112px",
|
||||
padding: "0 10px",
|
||||
borderRadius: "8px",
|
||||
background: "rgba(15, 23, 42, 0.04)",
|
||||
color: "#6b7280",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "5px",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
fontSize: "11px",
|
||||
fontWeight: 600
|
||||
}}
|
||||
className="meeting-card-v2__owner"
|
||||
title={`所属用户:${ownerName}`}
|
||||
>
|
||||
<UserOutlined style={{ fontSize: "12px", opacity: 0.75, flexShrink: 0 }} />
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{ownerName}
|
||||
</span>
|
||||
<UserOutlined />
|
||||
<span>{ownerName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -767,7 +705,7 @@ const Meetings: React.FC = () => {
|
|||
title: "会议标题",
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
render: (text: string, record: MeetingVO) => <a style={{ fontWeight: 500 }} onClick={() => handleOpenMeeting(record)}>{text}</a>,
|
||||
render: (text: string, record: MeetingVO) => <a className="meetings-table__title-link" onClick={() => handleOpenMeeting(record)}>{text}</a>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
|
|
@ -794,13 +732,13 @@ const Meetings: React.FC = () => {
|
|||
dataIndex: "meetingSource",
|
||||
key: "meetingSource",
|
||||
width: 80,
|
||||
render: (value: MeetingVO["meetingSource"]) => <Tag style={{ fontSize: '11px', margin: 0 }}>{getMeetingSourceLabel(value)}</Tag>,
|
||||
render: (value: MeetingVO["meetingSource"]) => <Tag className="meetings-table__source-tag">{getMeetingSourceLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "参会人",
|
||||
dataIndex: "participants",
|
||||
key: "participants",
|
||||
render: (text: string) => <Text type="secondary" ellipsis style={{ maxWidth: 180 }}>{text || "无参会人员"}</Text>,
|
||||
render: (text: string) => <Text type="secondary" ellipsis className="meetings-table__participants">{text || "无参会人员"}</Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
|
|
@ -845,110 +783,130 @@ const Meetings: React.FC = () => {
|
|||
|
||||
return (
|
||||
<PageContainer
|
||||
title="会议中心"
|
||||
subtitle="管理会议记录与分析"
|
||||
style={{ padding: '20px 32px' }}
|
||||
headerExtra={
|
||||
<Space size={16} wrap>
|
||||
<Radio.Group value={displayMode} onChange={(e) => handleDisplayModeChange(e.target.value)} buttonStyle="solid">
|
||||
<Radio.Button value="card"><AppstoreOutlined /></Radio.Button>
|
||||
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
|
||||
</Radio.Group>
|
||||
{configLoaded && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!createConfig.offlineEnabled && !createConfig.realtimeEnabled}
|
||||
onClick={() => {
|
||||
if (createConfig.offlineEnabled || createConfig.realtimeEnabled) {
|
||||
setCreateDrawerType(createConfig.offlineEnabled ? "upload" : "realtime");
|
||||
setCreateDrawerVisible(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
新建会议
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
toolbar={
|
||||
<Space wrap size={12} style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Radio.Group value={viewType} onChange={(e) => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
|
||||
<Radio.Button value="all">全部</Radio.Button>
|
||||
<Radio.Button value="created">我发起</Radio.Button>
|
||||
<Radio.Button value="involved">我参与</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Space wrap size={10} align="center">
|
||||
<Tag
|
||||
color={activeFilterCount > 0 ? "processing" : "default"}
|
||||
style={{ margin: 0, borderRadius: 999, paddingInline: 10, lineHeight: "24px" }}
|
||||
>
|
||||
<Space size={6}>
|
||||
<FilterOutlined />
|
||||
<span>{activeFilterCount > 0 ? `已筛选 ${activeFilterCount} 项` : "未筛选"}</span>
|
||||
</Space>
|
||||
</Tag>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(value) => { setStatusFilter(value); setCurrent(1); }}
|
||||
style={{ width: 170 }}
|
||||
popupMatchSelectWidth={false}
|
||||
suffixIcon={<FilterOutlined style={{ color: "#8c8c8c" }} />}
|
||||
options={MEETING_STATUS_FILTER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: (
|
||||
<Space size={8}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: option.color,
|
||||
boxShadow: `0 0 0 4px ${option.bgColor}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
<Search
|
||||
placeholder="搜索会议标题"
|
||||
value={searchKeyword}
|
||||
allowClear
|
||||
enterButton={false}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setSearchKeyword(nextValue);
|
||||
if (!nextValue) {
|
||||
setSearchTitle("");
|
||||
setCurrent(1);
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button onClick={handleResetFilters}>
|
||||
清空筛选
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
title={null}
|
||||
className="meetings-page"
|
||||
>
|
||||
<Card
|
||||
className="app-page__content-card"
|
||||
style={{ flex: 1, minHeight: 0, overflow: "hidden", display: "flex", flexDirection: "column", border: 'none', background: 'transparent' }}
|
||||
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
|
||||
<SectionCard
|
||||
title="会议中心"
|
||||
description="管理会议记录、处理进度与实时会议入口。"
|
||||
>
|
||||
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "hidden", padding: "4px" }}>
|
||||
<DataListPanel
|
||||
className={displayMode === "card" ? "meetings-list-panel meetings-list-panel--cards" : "meetings-list-panel"}
|
||||
toolbarClassName="meetings-list-toolbar"
|
||||
leftActions={
|
||||
<div className="meetings-toolbar-stack">
|
||||
<div className="meetings-toolbar-primary">
|
||||
<Space wrap size={10} align="center" className="meetings-primary-actions">
|
||||
<Radio.Group
|
||||
value={displayMode}
|
||||
onChange={(e) => handleDisplayModeChange(e.target.value)}
|
||||
buttonStyle="solid"
|
||||
className="meetings-display-switch"
|
||||
>
|
||||
<Radio.Button value="card"><AppstoreOutlined /></Radio.Button>
|
||||
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
|
||||
</Radio.Group>
|
||||
{configLoaded && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!createConfig.offlineEnabled && !createConfig.realtimeEnabled}
|
||||
onClick={() => {
|
||||
if (createConfig.offlineEnabled || createConfig.realtimeEnabled) {
|
||||
setCreateDrawerType(createConfig.offlineEnabled ? "upload" : "realtime");
|
||||
setCreateDrawerVisible(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
新建会议
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Space wrap size={10} align="center" className="meetings-filter-actions">
|
||||
<Tag
|
||||
color={activeFilterCount > 0 ? "processing" : "default"}
|
||||
className="meetings-filter-count"
|
||||
>
|
||||
<Space size={6}>
|
||||
<FilterOutlined />
|
||||
<span>{activeFilterCount > 0 ? `已筛选 ${activeFilterCount} 项` : "未筛选"}</span>
|
||||
</Space>
|
||||
</Tag>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(value) => { setStatusFilter(value); setCurrent(1); }}
|
||||
className="meetings-status-filter"
|
||||
popupMatchSelectWidth={false}
|
||||
suffixIcon={<FilterOutlined className="meetings-status-filter__suffix" />}
|
||||
options={MEETING_STATUS_FILTER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: (
|
||||
<Space size={8}>
|
||||
<span
|
||||
className="meetings-status-filter-dot"
|
||||
style={{
|
||||
background: option.color,
|
||||
}}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
<Search
|
||||
placeholder="搜索会议标题"
|
||||
value={searchKeyword}
|
||||
allowClear
|
||||
enterButton={false}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setSearchKeyword(nextValue);
|
||||
if (!nextValue) {
|
||||
setSearchTitle("");
|
||||
setCurrent(1);
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
className="meetings-search-input"
|
||||
/>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button onClick={handleResetFilters}>
|
||||
清空筛选
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="meetings-scope-row">
|
||||
<Radio.Group
|
||||
value={viewType}
|
||||
onChange={(e) => { setViewType(e.target.value); setCurrent(1); }}
|
||||
buttonStyle="solid"
|
||||
className="meetings-scope-switch"
|
||||
>
|
||||
<Radio.Button value="all">全部</Radio.Button>
|
||||
<Radio.Button value="created">我发起</Radio.Button>
|
||||
<Radio.Button value="involved">我参与</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
variant={displayMode === "card" ? "card" : "table"}
|
||||
current={current}
|
||||
pageSize={size}
|
||||
total={total}
|
||||
onChange={handlePaginationChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{displayMode === "card" ? (
|
||||
<div style={{ height: "100%", minHeight: 0, overflowY: "auto", overflowX: "hidden" }}>
|
||||
<div className="meetings-card-scroll">
|
||||
<Skeleton loading={loading} active paragraph={{ rows: 8 }}>
|
||||
<List
|
||||
grid={{ gutter: [20, 20], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
||||
grid={{ gutter: [12, 12], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
||||
dataSource={data}
|
||||
renderItem={(item) => {
|
||||
const progress = progressMap[item.id] || null;
|
||||
|
|
@ -979,22 +937,12 @@ const Meetings: React.FC = () => {
|
|||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: "max-content", y: "calc(100vh - 430px)" }}
|
||||
onRow={(record) => ({ onClick: () => handleOpenMeeting(record), style: { cursor: "pointer" } })}
|
||||
onRow={(record) => ({ onClick: () => handleOpenMeeting(record) })}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "16px 4px 0", flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
|
||||
<AppPagination
|
||||
variant={displayMode === "card" ? "card" : "table"}
|
||||
current={current}
|
||||
pageSize={size}
|
||||
total={total}
|
||||
onChange={handlePaginationChange}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<MeetingCreateDrawer
|
||||
open={createDrawerVisible}
|
||||
|
|
@ -1005,45 +953,6 @@ const Meetings: React.FC = () => {
|
|||
void fetchData();
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
.meeting-card-v2:hover { transform: translateY(-3px); box-shadow: 0 12px 24px rgba(95, 81, 255, 0.08) !important; border-color: rgba(95, 81, 255, 0.15) !important; }
|
||||
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
|
||||
@keyframes statusBreathing { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
|
||||
.meetings-list-table.ant-table-wrapper,
|
||||
.meetings-list-table.ant-table-wrapper .ant-spin-nested-loading,
|
||||
.meetings-list-table.ant-table-wrapper .ant-spin-container,
|
||||
.meetings-list-table.ant-table-wrapper .ant-table,
|
||||
.meetings-list-table.ant-table-wrapper .ant-table-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.meetings-list-table.ant-table-wrapper .ant-table-body {
|
||||
max-height: calc(100vh - 430px) !important;
|
||||
}
|
||||
|
||||
/* Premium Button Styles */
|
||||
.ant-btn-primary:not(.ant-btn-dangerous) {
|
||||
background: linear-gradient(135deg, #5f51ff, #6c8cff) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 12px rgba(95, 81, 255, 0.2) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
.ant-btn-primary:not(.ant-btn-dangerous):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(95, 81, 255, 0.3) !important;
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.ant-btn-default {
|
||||
border-color: rgba(228, 232, 245, 0.8) !important;
|
||||
background: #fff !important;
|
||||
color: #6e7695 !important;
|
||||
}
|
||||
.ant-btn-default:hover {
|
||||
border-color: #5f51ff !important;
|
||||
color: #5f51ff !important;
|
||||
}
|
||||
`}</style>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
.prompt-templates-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.prompt-templates-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prompt-templates-list-panel .data-list-panel__table-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prompt-templates-table.ant-table-wrapper,
|
||||
.prompt-templates-table.ant-table-wrapper .ant-spin-nested-loading,
|
||||
.prompt-templates-table.ant-table-wrapper .ant-spin-container,
|
||||
.prompt-templates-table.ant-table-wrapper .ant-table,
|
||||
.prompt-templates-table.ant-table-wrapper .ant-table-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.prompt-templates-table .ant-table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prompt-templates-table .ant-table-cell {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.prompt-template-name-cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prompt-template-name-cell > .ant-typography {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prompt-template-description.ant-typography {
|
||||
display: block;
|
||||
max-width: 360px;
|
||||
color: #9095a1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prompt-template-level-tag.ant-tag,
|
||||
.prompt-template-tags-cell .ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.prompt-templates-table .ant-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.prompt-templates-search {
|
||||
justify-content: flex-end;
|
||||
gap: 8px 0;
|
||||
}
|
||||
|
||||
.prompt-templates-search .ant-form-item {
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.prompt-templates-search__name {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.prompt-templates-search__category {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.prompt-template-detail {
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.prompt-template-detail__description {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
background: var(--app-bg-surface-soft);
|
||||
}
|
||||
|
||||
.prompt-template-detail__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.prompt-template-detail__section-title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prompt-template-drawer__title {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.prompt-template-editor {
|
||||
height: calc(100vh - 400px);
|
||||
}
|
||||
|
||||
.prompt-template-editor__col {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.prompt-template-editor__input {
|
||||
height: 100% !important;
|
||||
padding: 12px;
|
||||
resize: none;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
.prompt-template-editor__preview {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px;
|
||||
border: 1px solid var(--app-border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--app-bg-surface-soft);
|
||||
}
|
||||
|
||||
.prompt-template-editor__preview-meta {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.prompt-templates-search,
|
||||
.prompt-templates-search .ant-form-item,
|
||||
.prompt-templates-search .ant-input,
|
||||
.prompt-templates-search .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.prompt-template-editor {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.prompt-template-editor__col,
|
||||
.prompt-template-editor__preview {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,26 +2,27 @@ import React, { useEffect, useState } from 'react';
|
|||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, StarFilled } from '@ant-design/icons';
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDict } from '../../hooks/useDict';
|
||||
|
|
@ -36,31 +37,45 @@ import {
|
|||
} from '../../api/business/prompt';
|
||||
import { getHotWordGroupOptions, type HotWordGroupVO } from '../../api/business/hotwordGroup';
|
||||
import AppPagination from '../../components/shared/AppPagination';
|
||||
import './PromptTemplates.css';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const normalizePromptTags = (tags: unknown) => {
|
||||
if (Array.isArray(tags)) {
|
||||
return tags.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof tags === 'string') {
|
||||
return tags.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const PromptTemplates: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const { message, modal } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
const { items: categories, loading: dictLoading } = useDict('biz_prompt_category');
|
||||
const { items: dictTags } = useDict('biz_prompt_tag');
|
||||
const { items: promptLevels } = useDict('biz_prompt_level');
|
||||
const templateLevel = Form.useWatch('isSystem', form);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<PromptTemplateVO[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(8);
|
||||
const [queryDraft, setQueryDraft] = useState<{ name?: string; category?: string }>({});
|
||||
const [query, setQuery] = useState<{ name?: string; category?: string }>({});
|
||||
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState('');
|
||||
const [groupOptions, setGroupOptions] = useState<HotWordGroupVO[]>([]);
|
||||
const [templateLevel, setTemplateLevel] = useState<number | undefined>();
|
||||
const [selectedHotWordGroupId, setSelectedHotWordGroupId] = useState<number | undefined>();
|
||||
const [drawerInitialValues, setDrawerInitialValues] = useState<Record<string, any>>({});
|
||||
const [drawerFormKey, setDrawerFormKey] = useState(0);
|
||||
|
||||
const userProfile = React.useMemo(() => {
|
||||
const profileStr = sessionStorage.getItem("userProfile");
|
||||
|
|
@ -73,7 +88,7 @@ const PromptTemplates: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, [current, pageSize]);
|
||||
}, [current, pageSize, query]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadGroupOptions();
|
||||
|
|
@ -86,18 +101,17 @@ const PromptTemplates: React.FC = () => {
|
|||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
const values = searchForm.getFieldsValue();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getPromptPage({
|
||||
current,
|
||||
size: pageSize,
|
||||
name: values.name,
|
||||
category: values.category,
|
||||
name: query.name,
|
||||
category: query.category,
|
||||
});
|
||||
if (res.data?.data) {
|
||||
setData(res.data.data.records);
|
||||
setTotal(res.data.data.total);
|
||||
setData(res.data.data.records || []);
|
||||
setTotal(res.data.data.total || 0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -114,8 +128,11 @@ const PromptTemplates: React.FC = () => {
|
|||
if (record) {
|
||||
if (isClone) {
|
||||
setEditingId(null);
|
||||
form.setFieldsValue({
|
||||
setTemplateLevel(0);
|
||||
setSelectedHotWordGroupId(record.hotWordGroupId);
|
||||
setDrawerInitialValues({
|
||||
...record,
|
||||
tags: normalizePromptTags(record.tags),
|
||||
templateName: `${record.templateName} (副本)`,
|
||||
isSystem: 0,
|
||||
id: undefined,
|
||||
|
|
@ -141,18 +158,26 @@ const PromptTemplates: React.FC = () => {
|
|||
}
|
||||
|
||||
setEditingId(record.id);
|
||||
form.setFieldsValue(record);
|
||||
setTemplateLevel(Number(record.isSystem));
|
||||
setSelectedHotWordGroupId(record.hotWordGroupId);
|
||||
setDrawerInitialValues({
|
||||
...record,
|
||||
tags: normalizePromptTags(record.tags),
|
||||
});
|
||||
setPreviewContent(record.promptContent);
|
||||
}
|
||||
} else {
|
||||
const defaultLevel = (isTenantAdmin || isPlatformAdmin) ? 1 : 0;
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
setDrawerInitialValues({
|
||||
status: 1,
|
||||
isSystem: (isTenantAdmin || isPlatformAdmin) ? 1 : 0,
|
||||
isSystem: defaultLevel,
|
||||
});
|
||||
setTemplateLevel(defaultLevel);
|
||||
setSelectedHotWordGroupId(undefined);
|
||||
setPreviewContent('');
|
||||
}
|
||||
setDrawerFormKey((key) => key + 1);
|
||||
setDrawerVisible(true);
|
||||
};
|
||||
|
||||
|
|
@ -160,35 +185,35 @@ const PromptTemplates: React.FC = () => {
|
|||
void (async () => {
|
||||
const detailRes = await getPromptDetail(record.id);
|
||||
const detail = detailRes.data?.data || record;
|
||||
Modal.info({
|
||||
modal.info({
|
||||
title: record.templateName,
|
||||
width: 800,
|
||||
icon: null,
|
||||
content: (
|
||||
<div style={{ maxHeight: '65vh', overflowY: 'auto', padding: '12px 0' }}>
|
||||
<div className="prompt-template-detail">
|
||||
{detail.description ? (
|
||||
<div style={{ marginBottom: 16, padding: '12px 16px', borderRadius: 8, background: 'var(--app-bg-surface-soft)' }}>
|
||||
<div className="prompt-template-detail__description">
|
||||
<Text type="secondary">{detail.description}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="prompt-template-detail__section">
|
||||
<Space wrap>
|
||||
{detail.hotWordGroupName ? <Tag color="blue">热词组:{detail.hotWordGroupName}</Tag> : <Tag>未绑定热词组</Tag>}
|
||||
{(detail.tags || []).map((tag) => {
|
||||
{normalizePromptTags(detail.tags).map((tag) => {
|
||||
const dictItem = dictTags.find((item) => item.itemValue === tag);
|
||||
return <Tag key={tag}>{dictItem ? dictItem.itemLabel : tag}</Tag>;
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
{detail.hotWords && detail.hotWords.length > 0 ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>绑定热词</div>
|
||||
<div className="prompt-template-detail__section">
|
||||
<div className="prompt-template-detail__section-title">绑定热词</div>
|
||||
<Space wrap>
|
||||
{detail.hotWords.map((word) => <Tag key={word}>{word}</Tag>)}
|
||||
</Space>
|
||||
</div>
|
||||
) : detail.hotWordGroupId ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="prompt-template-detail__section">
|
||||
<Text type="secondary">该热词组当前没有热词</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -201,9 +226,8 @@ const PromptTemplates: React.FC = () => {
|
|||
})();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitLoading(true);
|
||||
if (!editingId && isPlatformAdmin && values.isSystem === 1) {
|
||||
values.tenantId = 0;
|
||||
|
|
@ -222,96 +246,137 @@ const PromptTemplates: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const groupedData = React.useMemo(() => {
|
||||
const groups: Record<string, PromptTemplateVO[]> = {};
|
||||
data.forEach((item) => {
|
||||
const cat = item.category || 'default';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(item);
|
||||
});
|
||||
return groups;
|
||||
}, [data]);
|
||||
|
||||
const renderCard = (item: PromptTemplateVO) => {
|
||||
const getTemplateLevelMeta = (item: PromptTemplateVO) => {
|
||||
const isSystem = item.isSystem === 1;
|
||||
const isPlatformLevel = Number(item.tenantId) === 0 && isSystem;
|
||||
const isTenantLevel = Number(item.tenantId) > 0 && isSystem;
|
||||
if (isPlatformLevel) {
|
||||
return { label: '平台级', color: 'gold' as const };
|
||||
}
|
||||
if (isTenantLevel) {
|
||||
return { label: '租户级', color: 'blue' as const };
|
||||
}
|
||||
return { label: '个人级', color: 'cyan' as const };
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrent(1);
|
||||
setQuery(queryDraft);
|
||||
};
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setQueryDraft({});
|
||||
setCurrent(1);
|
||||
setQuery({});
|
||||
};
|
||||
|
||||
const canManageTemplate = (item: PromptTemplateVO) => {
|
||||
const isSystem = item.isSystem === 1;
|
||||
const isPlatformLevel = Number(item.tenantId) === 0 && isSystem;
|
||||
const isPersonalLevel = !isSystem;
|
||||
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
|
||||
|
||||
let canEdit = false;
|
||||
if (isPersonalLevel) {
|
||||
canEdit = Number(item.creatorId) === currentUserId;
|
||||
} else if (isPlatformAdmin) {
|
||||
canEdit = Number(item.tenantId) === 0;
|
||||
} else if (isTenantAdmin) {
|
||||
canEdit = Number(item.tenantId) === activeTenantId;
|
||||
return Number(item.creatorId) === currentUserId;
|
||||
}
|
||||
if (isPlatformAdmin) {
|
||||
return isPlatformLevel;
|
||||
}
|
||||
if (isTenantAdmin) {
|
||||
return Number(item.tenantId) === activeTenantId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const levelTag = isPlatformLevel ? (
|
||||
<Tag color="gold" style={{ borderRadius: 4 }}>平台级</Tag>
|
||||
) : isTenantLevel ? (
|
||||
<Tag color="blue" style={{ borderRadius: 4 }}>租户级</Tag>
|
||||
) : (
|
||||
<Tag color="cyan" style={{ borderRadius: 4 }}>个人级</Tag>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
hoverable
|
||||
onClick={() => showDetail(item)}
|
||||
style={{ width: 320, borderRadius: 12, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)', position: 'relative', overflow: 'hidden' }}
|
||||
styles={{ body: { padding: '24px' } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: isPlatformLevel ? 'color-mix(in srgb, #f5c542 14%, var(--app-bg-surface-strong))' : (isTenantLevel ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'color-mix(in srgb, #13c2c2 12%, var(--app-bg-surface-strong))'),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>{levelTag}</div>
|
||||
</div>
|
||||
<Space onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0, marginLeft: 8 }}>
|
||||
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
|
||||
<Switch size="small" checked={item.status === 1} onChange={(checked) => void handleStatusChange(item.id, checked)} />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong style={{ fontSize: 16, display: 'block', width: '100%' }} ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
|
||||
const tableColumns: ColumnsType<PromptTemplateVO> = [
|
||||
{
|
||||
title: '模板名称',
|
||||
dataIndex: 'templateName',
|
||||
width: 280,
|
||||
render: (_: unknown, item: PromptTemplateVO) => (
|
||||
<div className="prompt-template-name-cell">
|
||||
<Text strong ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
|
||||
{item.description ? (
|
||||
<Text type="secondary" style={{ fontSize: 12, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', marginTop: 6 }}>
|
||||
<Text type="secondary" className="prompt-template-description" ellipsis={{ tooltip: item.description }}>
|
||||
{item.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12, minHeight: 22 }}>
|
||||
{item.hotWordGroupName ? <Tag color="blue" style={{ margin: 0 }}>热词组:{item.hotWordGroupName}</Tag> : null}
|
||||
{item.tags?.map((tag) => {
|
||||
const dictItem = dictTags.find((dt) => dt.itemValue === tag);
|
||||
return (
|
||||
<Tag key={tag} style={{ margin: 0, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-soft)', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 10, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{dictItem ? dictItem.itemLabel : tag}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{!item.hotWordGroupName && (!item.tags || item.tags.length === 0) ? <Text type="secondary">未配置标签</Text> : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', paddingTop: 12, borderTop: '1px solid #f5f5f5' }}>
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 140,
|
||||
render: (category: string) => categories.find((c) => c.itemValue === category)?.itemLabel || category || '-',
|
||||
},
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'isSystem',
|
||||
width: 110,
|
||||
render: (_: unknown, item: PromptTemplateVO) => {
|
||||
const level = getTemplateLevelMeta(item);
|
||||
return <Tag color={level.color} className="prompt-template-level-tag">{level.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '热词组',
|
||||
dataIndex: 'hotWordGroupName',
|
||||
width: 180,
|
||||
render: (name: string) => name ? <Tag color="blue">{name}</Tag> : <Text type="secondary">未绑定</Text>,
|
||||
},
|
||||
{
|
||||
title: '业务标签',
|
||||
dataIndex: 'tags',
|
||||
minWidth: 220,
|
||||
render: (tags: unknown) => {
|
||||
const tagList = normalizePromptTags(tags);
|
||||
if (!tagList.length) {
|
||||
return <Text type="secondary">未配置</Text>;
|
||||
}
|
||||
return (
|
||||
<Space size={[4, 4]} wrap className="prompt-template-tags-cell">
|
||||
{tagList.slice(0, 3).map((tag) => {
|
||||
const dictItem = dictTags.find((dt) => dt.itemValue === tag);
|
||||
return <Tag key={tag}>{dictItem ? dictItem.itemLabel : tag}</Tag>;
|
||||
})}
|
||||
{tagList.length > 3 ? <Tag>+{tagList.length - 3}</Tag> : null}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (_: unknown, item: PromptTemplateVO) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={item.status === 1}
|
||||
onClick={(_, event) => event.stopPropagation()}
|
||||
onChange={(checked) => void handleStatusChange(item.id, checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, item: PromptTemplateVO) => {
|
||||
const canEdit = canManageTemplate(item);
|
||||
return (
|
||||
<Space size={2} onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title="查看">
|
||||
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => showDetail(item)} />
|
||||
</Tooltip>
|
||||
{canEdit && (
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => handleOpenDrawer(item)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="以此创建">
|
||||
<CopyOutlined style={{ color: '#bfbfbf', cursor: 'pointer', fontSize: 16 }} onClick={() => handleOpenDrawer(item, true)} />
|
||||
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => handleOpenDrawer(item, true)} />
|
||||
</Tooltip>
|
||||
{canEdit && (
|
||||
<Popconfirm
|
||||
|
|
@ -321,93 +386,111 @@ const PromptTemplates: React.FC = () => {
|
|||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<DeleteOutlined style={{ color: '#bfbfbf', cursor: 'pointer', fontSize: 16 }} />
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="提示词模板"
|
||||
subtitle="管理AI会议总结的提示词模板库"
|
||||
headerExtra={
|
||||
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => handleOpenDrawer()} style={{ borderRadius: 6 }}>
|
||||
新增模板
|
||||
</Button>
|
||||
}
|
||||
toolbar={
|
||||
<Form form={searchForm} layout="inline" onFinish={() => void fetchData()}>
|
||||
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Select placeholder="选择分类" style={{ width: 160 }} allowClear>
|
||||
{categories.map((c) => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询数据</Button>
|
||||
<Button onClick={() => { searchForm.resetFields(); void fetchData(); }}>重置</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
>
|
||||
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
|
||||
<Skeleton loading={loading} active style={{ height: '100%' }}>
|
||||
{Object.keys(groupedData).length === 0 ? (
|
||||
<div className="app-page__empty-state" style={{ padding: 24 }}>
|
||||
<Empty description="暂无可用模板" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '24px 24px 0' }}>
|
||||
{Object.keys(groupedData).map((catKey) => {
|
||||
const catLabel = categories.find((c) => c.itemValue === catKey)?.itemLabel || catKey;
|
||||
return (
|
||||
<div key={catKey} style={{ marginBottom: 40 }}>
|
||||
<Title level={4} style={{ marginBottom: 24, paddingLeft: 8, borderLeft: '4px solid #1890ff' }}>{catLabel}</Title>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
|
||||
{groupedData[catKey].map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<AppPagination
|
||||
variant="card"
|
||||
current={current}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={(page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
}}
|
||||
<PageContainer title={null} className="prompt-templates-page">
|
||||
<SectionCard
|
||||
title="提示词模板"
|
||||
description="管理 AI 会议总结的提示词模板库。"
|
||||
>
|
||||
<DataListPanel
|
||||
className="prompt-templates-list-panel"
|
||||
leftActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenDrawer()}>
|
||||
新增模板
|
||||
</Button>
|
||||
}
|
||||
rightActions={
|
||||
<Form layout="inline" onFinish={handleSearch} className="prompt-templates-search">
|
||||
<Form.Item label="模板名称">
|
||||
<Input
|
||||
placeholder="请输入..."
|
||||
className="prompt-templates-search__name"
|
||||
value={queryDraft.name}
|
||||
onChange={(event) => setQueryDraft((currentDraft) => ({ ...currentDraft, name: event.target.value }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Skeleton>
|
||||
</Card>
|
||||
</Form.Item>
|
||||
<Form.Item label="分类">
|
||||
<Select
|
||||
placeholder="选择分类"
|
||||
className="prompt-templates-search__category"
|
||||
allowClear
|
||||
value={queryDraft.category}
|
||||
onChange={(value) => setQueryDraft((currentDraft) => ({ ...currentDraft, category: value }))}
|
||||
>
|
||||
{categories.map((c) => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={handleResetSearch}>重置</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={current}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={(page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table<PromptTemplateVO>
|
||||
className="prompt-templates-table"
|
||||
columns={tableColumns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: "max-content", y: "100%" }}
|
||||
onRow={(record) => ({ onClick: () => showDetail(record) })}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可用模板" /> }}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer
|
||||
title={<Title level={4} style={{ margin: 0 }}>{editingId ? '编辑模板' : '创建新模板'}</Title>}
|
||||
title={<Title level={4} className="prompt-template-drawer__title">{editingId ? '编辑模板' : '创建新模板'}</Title>}
|
||||
width="80%"
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
open={drawerVisible}
|
||||
forceRender
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={() => void handleSubmit()}>保存</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} htmlType="submit" form="prompt-template-form">保存</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form
|
||||
id="prompt-template-form"
|
||||
key={drawerFormKey}
|
||||
initialValues={drawerInitialValues}
|
||||
layout="vertical"
|
||||
onFinish={(values) => void handleSubmit(values)}
|
||||
onValuesChange={(changedValues) => {
|
||||
if (Object.prototype.hasOwnProperty.call(changedValues, 'isSystem')) {
|
||||
setTemplateLevel(Number(changedValues.isSystem));
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(changedValues, 'hotWordGroupId')) {
|
||||
setSelectedHotWordGroupId(changedValues.hotWordGroupId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={(isPlatformAdmin || isTenantAdmin) ? 8 : 12}>
|
||||
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}>
|
||||
|
|
@ -480,22 +563,22 @@ const PromptTemplates: React.FC = () => {
|
|||
</Row>
|
||||
|
||||
<Divider orientation="left">提示词编辑器 (Markdown 实时预览)</Divider>
|
||||
<Row gutter={24} style={{ height: 'calc(100vh - 400px)' }}>
|
||||
<Col span={12} style={{ height: '100%' }}>
|
||||
<Row gutter={24} className="prompt-template-editor">
|
||||
<Col span={12} className="prompt-template-editor__col">
|
||||
<Form.Item name="promptContent" noStyle rules={[{ required: true }]}>
|
||||
<Input.TextArea
|
||||
onChange={(e) => setPreviewContent(e.target.value)}
|
||||
style={{ height: '100%', fontFamily: 'monospace', resize: 'none', border: '1px solid #d9d9d9', borderRadius: 8, padding: 12 }}
|
||||
className="prompt-template-editor__input"
|
||||
placeholder="在此输入 Markdown 指令..."
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12} style={{ height: '100%', overflowY: 'auto', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', borderRadius: 8, padding: '16px 24px' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{form.getFieldValue('hotWordGroupId') ? (
|
||||
<Col span={12} className="prompt-template-editor__preview">
|
||||
<div className="prompt-template-editor__preview-meta">
|
||||
{selectedHotWordGroupId ? (
|
||||
<Tag color="blue">
|
||||
绑定热词组:
|
||||
{groupOptions.find((item) => item.id === form.getFieldValue('hotWordGroupId'))?.groupName || '已选择'}
|
||||
{groupOptions.find((item) => item.id === selectedHotWordGroupId)?.groupName || '已选择'}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>未绑定热词组</Tag>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
.public-device-create-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px 16px;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.public-device-create-shell {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.public-device-create-card {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.public-device-create-card .ant-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.public-device-create-loading {
|
||||
padding: 64px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-device-create-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.public-device-create-head {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.public-device-create-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3c70f5;
|
||||
background: #eef4ff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.public-device-create-title.ant-typography {
|
||||
margin: 0 0 8px;
|
||||
color: #333333;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 30px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.public-device-create-notice {
|
||||
border: 1px solid #d8e3ff;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.public-device-create-notice .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.public-device-create-check {
|
||||
margin-right: 8px;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.public-device-create-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.public-device-create-page {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.public-device-create-card .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.public-device-create-head.ant-space {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.public-device-create-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.public-device-create-title.ant-typography {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.public-device-create-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.public-device-create-actions .ant-space {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { createPublicDeviceMeetingBySession } from "@/api/business/meeting";
|
||||
import "./PublicDeviceMeetingCreate.css";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
|
@ -43,23 +44,11 @@ export default function PublicDeviceMeetingCreate() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(180deg, #f7fbff 0%, #eef4f8 100%)",
|
||||
padding: "48px 16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
boxShadow: "0 24px 64px rgba(15, 49, 86, 0.08)",
|
||||
border: "1px solid #d9e6f2",
|
||||
}}
|
||||
>
|
||||
<div className="public-device-create-page">
|
||||
<div className="public-device-create-shell">
|
||||
<Card className="public-device-create-card">
|
||||
{!ready ? (
|
||||
<div style={{ padding: "64px 0", textAlign: "center" }}>
|
||||
<div className="public-device-create-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : !sessionId ? (
|
||||
|
|
@ -85,25 +74,13 @@ export default function PublicDeviceMeetingCreate() {
|
|||
}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={24} style={{ width: "100%" }}>
|
||||
<Space size={16} align="start">
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
background: "#e6f4ff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#1677ff",
|
||||
fontSize: 24,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={24} className="public-device-create-content">
|
||||
<Space size={16} align="start" className="public-device-create-head">
|
||||
<div className="public-device-create-icon">
|
||||
<QrcodeOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
<Title level={3} className="public-device-create-title">
|
||||
公有设备扫码登录确认
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
|
|
@ -112,17 +89,10 @@ export default function PublicDeviceMeetingCreate() {
|
|||
</div>
|
||||
</Space>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{
|
||||
background: "#f6fbff",
|
||||
border: "1px solid #d7e8f6",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<Card bordered={false} className="public-device-create-notice">
|
||||
<Space direction="vertical" size={12}>
|
||||
<Text>
|
||||
<CheckCircleOutlined style={{ color: "#1677ff", marginRight: 8 }} />
|
||||
<CheckCircleOutlined className="public-device-create-check" />
|
||||
设备端收到确认消息后,才允许后续离线发会创建会议。
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
|
|
@ -131,7 +101,7 @@ export default function PublicDeviceMeetingCreate() {
|
|||
</Space>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div className="public-device-create-actions">
|
||||
<Space>
|
||||
<Button size="large" onClick={() => navigate(-1)}>
|
||||
返回
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
.realtime-session-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.realtime-session-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.realtime-session-title {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.realtime-session-title > .anticon {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.realtime-session-title > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.realtime-session-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
color: #9095a1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.realtime-session-meta strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.realtime-session-section-content {
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.realtime-session-body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.realtime-session-alert-wrap {
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.realtime-session-console {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.transcript-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px 132px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.transcript-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.transcript-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.transcript-container::-webkit-scrollbar-thumb {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(148, 163, 184, 0.34);
|
||||
}
|
||||
|
||||
.realtime-session-empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.transcript-stream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.live-transcript-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.transcript-avatar-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.transcript-avatar.ant-avatar {
|
||||
border: none;
|
||||
background: #3c70f5;
|
||||
}
|
||||
|
||||
.transcript-avatar--draft.ant-avatar {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.transcript-entry {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.transcript-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.transcript-speaker {
|
||||
color: #596275;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.transcript-speaker--draft {
|
||||
color: #9095a1;
|
||||
}
|
||||
|
||||
.transcript-time {
|
||||
color: #9095a1;
|
||||
font-family: Consolas, "Liberation Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.transcript-bubble {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.live-transcript-row.draft .transcript-bubble {
|
||||
background: #f3f6ff;
|
||||
color: #596275;
|
||||
}
|
||||
|
||||
.realtime-control-bar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
z-index: 10;
|
||||
min-width: 380px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.realtime-control-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.realtime-control-orb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
color: #9095a1;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.realtime-control-orb.recording-orb {
|
||||
background: #3c70f5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.realtime-control-orb .anticon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.realtime-control-state {
|
||||
margin-bottom: 2px;
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.realtime-control-clock {
|
||||
color: #9095a1;
|
||||
font-family: Consolas, "Liberation Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.realtime-control-btn.ant-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.realtime-control-btn--danger.ant-btn {
|
||||
border: none;
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.realtime-control-btn--pause.ant-btn {
|
||||
border: none;
|
||||
background: #f3f6ff;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.realtime-session-loading-card.ant-card {
|
||||
flex: 1;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.realtime-session-loading {
|
||||
display: grid;
|
||||
min-height: 320px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.realtime-session-title > span:last-child {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.transcript-container {
|
||||
padding: 16px 12px 160px;
|
||||
}
|
||||
|
||||
.realtime-control-bar {
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
bottom: 16px;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Avatar, Badge, Button, Card, Col, Empty, Row, Space, Statistic, Tag, Typography, App } from 'antd';
|
||||
import { Alert, Avatar, Badge, Button, Card, Empty, Space, Typography, App } from 'antd';
|
||||
import {
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
ClockCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
SoundOutlined,
|
||||
SyncOutlined,
|
||||
UserOutlined,
|
||||
|
|
@ -14,9 +11,9 @@ import {
|
|||
CaretRightOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
import PageHeader from "../../components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import "./RealtimeAsrSession.css";
|
||||
import {
|
||||
completeRealtimeMeeting,
|
||||
getMeetingDetail,
|
||||
|
|
@ -29,7 +26,7 @@ import {
|
|||
type RealtimeMeetingSessionStatus,
|
||||
type RealtimeSocketSessionVO,
|
||||
} from "../../api/business/meeting";
|
||||
const { Text, Title } = Typography;
|
||||
const { Text } = Typography;
|
||||
const SAMPLE_RATE = 16000;
|
||||
const CHUNK_SIZE = 1280;
|
||||
const CURRENT_PLATFORM = "WEB" as const;
|
||||
|
|
@ -797,178 +794,74 @@ export function RealtimeAsrSession() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 18 }}>
|
||||
<div style={{ textAlign: "center", padding: "96px 0" }}>
|
||||
<PageContainer title={null} className="realtime-session-page">
|
||||
<SectionCard title="实时转写" description="正在加载实时会议控制台。">
|
||||
<Card variant="borderless" className="realtime-session-loading-card">
|
||||
<div className="realtime-session-loading">
|
||||
<SyncOutlined spin />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!meeting) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card variant="borderless" style={{ borderRadius: 18 }}>
|
||||
<PageContainer title={null} className="realtime-session-page">
|
||||
<SectionCard title="实时转写" description="会议不存在或已无法访问。">
|
||||
<Card variant="borderless" className="realtime-session-loading-card">
|
||||
<Empty description="会议不存在" />
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden", background: "#f8fafc" }}>
|
||||
<style>{`
|
||||
.live-transcript-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
<PageContainer title={null} className="realtime-session-page">
|
||||
<SectionCard
|
||||
title={
|
||||
<span className="realtime-session-title">
|
||||
<SoundOutlined />
|
||||
<span>{meeting.title || "实时识别中"}</span>
|
||||
</span>
|
||||
}
|
||||
.transcript-avatar-container {
|
||||
flex-shrink: 0;
|
||||
description={
|
||||
<span className="realtime-session-meta">
|
||||
<Badge color={statusColor} text={<span>{statusText}</span>} />
|
||||
{sessionDraft ? <span>字数 <strong>{totalTranscriptChars}</strong></span> : null}
|
||||
</span>
|
||||
}
|
||||
.transcript-entry {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.transcript-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.transcript-speaker {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.transcript-time {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.transcript-bubble {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.04);
|
||||
color: #334155;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border: none;
|
||||
}
|
||||
.live-transcript-row.draft .transcript-bubble {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
@keyframes orb-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5); }
|
||||
70% { box-shadow: 0 0 0 15px rgba(59, 130, 246, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
|
||||
}
|
||||
.recording-orb {
|
||||
animation: orb-pulse 2s infinite;
|
||||
}
|
||||
/* Custom scrollbar for transcript area */
|
||||
.transcript-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.transcript-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.transcript-container::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 极简顶部信息栏 */}
|
||||
<div style={{
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
backdropFilter: "blur(8px)",
|
||||
borderBottom: "1px solid rgba(226, 232, 240, 0.8)",
|
||||
padding: "16px 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
zIndex: 10,
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.02)"
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<Title level={4} style={{ margin: 0, color: "#0f172a" }}>{meeting.title || "实时识别中"}</Title>
|
||||
<Badge color={statusColor} text={<span style={{ color: "#475569", fontWeight: 500 }}>{statusText}</span>} />
|
||||
</div>
|
||||
|
||||
{sessionDraft && (
|
||||
<Space size={12} split={<div style={{ width: 1, height: 12, background: '#cbd5e1' }} />}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
字数 <strong style={{ color: "#334155" }}>{totalTranscriptChars}</strong>
|
||||
</Text>
|
||||
{/*<Text type="secondary" style={{ fontSize: 13 }}>*/}
|
||||
{/* 模型 <strong style={{ color: "#334155" }}>{sessionDraft.asrModelName}</strong>*/}
|
||||
{/*</Text>*/}
|
||||
{/*<Text type="secondary" style={{ fontSize: 13 }}>*/}
|
||||
{/* 模式 <strong style={{ color: "#334155" }}>{sessionDraft.mode}</strong>*/}
|
||||
{/*</Text>*/}
|
||||
{/*{sessionDraft.hotwords && sessionDraft.hotwords.length > 0 && (*/}
|
||||
{/* <Text type="secondary" style={{ fontSize: 13 }}>*/}
|
||||
{/* 热词 <strong style={{ color: "#334155" }}>{sessionDraft.hotwords.length}</strong>*/}
|
||||
{/* </Text>*/}
|
||||
{/*)}*/}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative", display: "flex", padding: "0 24px" }}>
|
||||
contentClassName="realtime-session-section-content"
|
||||
>
|
||||
<div className="realtime-session-body">
|
||||
{!sessionDraft ? (
|
||||
<div style={{ width: "100%", padding: 40 }}>
|
||||
<div className="realtime-session-alert-wrap">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="缺少实时识别启动配置"
|
||||
description="这个会议的实时识别配置没有保存在当前浏览器中,请返回创建页重新进入。"
|
||||
action={<Button size="small" onClick={() => navigate("/meetings?action=create&type=realtime")}>返回创建页</Button>}
|
||||
style={{ borderRadius: 12, border: "none", boxShadow: "0 4px 12px rgba(0,0,0,0.05)" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
|
||||
{/* 核心转写区域 */}
|
||||
<div
|
||||
ref={transcriptRef}
|
||||
className="transcript-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
padding: "32px 0px 140px",
|
||||
scrollBehavior: "smooth"
|
||||
}}
|
||||
>
|
||||
<div className="realtime-session-console">
|
||||
<div ref={transcriptRef} className="transcript-container">
|
||||
{transcripts.length === 0 && !streamingText ? (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", opacity: 0.6 }}>
|
||||
<div className="realtime-session-empty">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={hasRemoteActiveConnection ? "当前会议已有活跃连接,请先关闭旧连接后再继续。" : "会议已就绪,点击下方按钮开始识别。"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div className="transcript-stream">
|
||||
{transcripts.map((item) => (
|
||||
<div key={item.id} className="live-transcript-row">
|
||||
<div className="transcript-avatar-container">
|
||||
<Avatar size={36} icon={<UserOutlined />} style={{ background: "linear-gradient(135deg, #7a84ff, #9363ff)", border: "none" }} />
|
||||
<Avatar size={36} icon={<UserOutlined />} className="transcript-avatar" />
|
||||
</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
|
|
@ -983,11 +876,11 @@ export function RealtimeAsrSession() {
|
|||
{streamingText ? (
|
||||
<div className="live-transcript-row draft">
|
||||
<div className="transcript-avatar-container">
|
||||
<Avatar size={36} icon={<UserOutlined />} style={{ background: "#cbd5e1", border: "none" }} />
|
||||
<Avatar size={36} icon={<UserOutlined />} className="transcript-avatar transcript-avatar--draft" />
|
||||
</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
<span className="transcript-speaker" style={{ color: "#94a3b8" }}>{streamingSpeaker}</span>
|
||||
<span className="transcript-speaker transcript-speaker--draft">{streamingSpeaker}</span>
|
||||
<span className="transcript-time">...</span>
|
||||
</div>
|
||||
<div className="transcript-bubble">{streamingText}</div>
|
||||
|
|
@ -998,41 +891,19 @@ export function RealtimeAsrSession() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部悬浮控制条 */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: 32,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "auto",
|
||||
minWidth: 380,
|
||||
padding: "12px 20px",
|
||||
background: "rgba(255, 255, 255, 0.85)",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderRadius: 36,
|
||||
boxShadow: "0 12px 36px rgba(0,0,0,0.12)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 40,
|
||||
zIndex: 100,
|
||||
border: "1px solid rgba(255,255,255,0.6)"
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div className={recording ? "recording-orb" : ""} style={{
|
||||
width: 48, height: 48, borderRadius: "50%",
|
||||
background: recording ? "linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%)" : "#f0f0f0",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
transition: "transform 0.1s",
|
||||
transform: recording ? `scale(${1 + audioLevel / 300})` : "scale(1)"
|
||||
}}>
|
||||
{recording ? <AudioOutlined style={{ color: "#fff", fontSize: 20 }} /> : <AudioMutedOutlined style={{ color: "#999", fontSize: 20 }} />}
|
||||
<div className="realtime-control-bar">
|
||||
<div className="realtime-control-status">
|
||||
<div
|
||||
className={recording ? "realtime-control-orb recording-orb" : "realtime-control-orb"}
|
||||
style={{ transform: recording ? `scale(${1 + audioLevel / 300})` : "scale(1)" }}
|
||||
>
|
||||
{recording ? <AudioOutlined /> : <AudioMutedOutlined />}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: "#333", marginBottom: 2 }}>
|
||||
<div className="realtime-control-state">
|
||||
{recording ? "录音中..." : connecting ? "连接中..." : sessionStatus?.status === "PAUSED_RESUMABLE" || sessionStatus?.status === "PAUSED_EMPTY" ? "已暂停" : "待命"}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#888", fontFamily: "monospace" }}>
|
||||
<div className="realtime-control-clock">
|
||||
{formatClock(elapsedSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1047,7 +918,7 @@ export function RealtimeAsrSession() {
|
|||
disabled={(!recording && !connecting && !sessionStatus?.hasTranscript) || finishing || pausing}
|
||||
loading={finishing}
|
||||
onClick={() => void handleStop(true)}
|
||||
style={{ border: "none", background: "#fff1f0", color: "#ff4d4f", width: 44, height: 44 }}
|
||||
className="realtime-control-btn realtime-control-btn--danger"
|
||||
/>
|
||||
{recording ? (
|
||||
<Button
|
||||
|
|
@ -1056,7 +927,7 @@ export function RealtimeAsrSession() {
|
|||
icon={<PauseOutlined />}
|
||||
loading={pausing}
|
||||
onClick={() => void handlePause()}
|
||||
style={{ border: "none", background: "#f0f5ff", color: "#2f54eb", width: 44, height: 44 }}
|
||||
className="realtime-control-btn realtime-control-btn--pause"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -1067,7 +938,7 @@ export function RealtimeAsrSession() {
|
|||
loading={connecting}
|
||||
disabled={connecting || finishing || pausing || hasRemoteActiveConnection}
|
||||
onClick={() => void handleStart()}
|
||||
style={{ width: 44, height: 44, boxShadow: "0 4px 12px rgba(24, 144, 255, 0.3)" }}
|
||||
className="realtime-control-btn"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
|
@ -1075,7 +946,8 @@ export function RealtimeAsrSession() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,59 +1,44 @@
|
|||
.screen-saver-page {
|
||||
--screen-saver-border: rgba(15, 23, 42, 0.08);
|
||||
--screen-saver-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
|
||||
--screen-saver-accent: #1677ff;
|
||||
--screen-saver-dark: #10233f;
|
||||
--screen-saver-muted: #5b6b84;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-card {
|
||||
border: 1px solid var(--screen-saver-border);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: var(--screen-saver-shadow);
|
||||
.screen-saver-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-card .ant-card-head {
|
||||
padding: 0 24px;
|
||||
min-height: 72px;
|
||||
.screen-saver-page .screen-saver-list-panel .data-list-panel__table-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-card .ant-card-head-wrapper {
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-card .ant-card-head-title {
|
||||
color: var(--screen-saver-dark);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-card .ant-card-extra {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-wrap {
|
||||
.screen-saver-page .screen-saver-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 24px 24px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-wrap .ant-table-wrapper,
|
||||
.screen-saver-page .screen-saver-table-wrap .ant-spin-nested-loading,
|
||||
.screen-saver-page .screen-saver-table-wrap .ant-spin-container,
|
||||
.screen-saver-page .screen-saver-table-wrap .ant-table,
|
||||
.screen-saver-page .screen-saver-table-wrap .ant-table-container {
|
||||
.screen-saver-page .screen-saver-table.ant-table-wrapper,
|
||||
.screen-saver-page .screen-saver-table .ant-spin-nested-loading,
|
||||
.screen-saver-page .screen-saver-table .ant-spin-container,
|
||||
.screen-saver-page .screen-saver-table .ant-table,
|
||||
.screen-saver-page .screen-saver-table .ant-table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-wrap .ant-table-body {
|
||||
.screen-saver-page .screen-saver-table .ant-table-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto !important;
|
||||
|
|
@ -65,9 +50,10 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
color: var(--screen-saver-accent);
|
||||
border: 1px solid #d8e3ff;
|
||||
border-radius: 4px;
|
||||
background: #f3f7ff;
|
||||
color: #3c70f5;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -82,9 +68,9 @@
|
|||
width: 120px;
|
||||
aspect-ratio: 8 / 5;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: linear-gradient(135deg, rgba(222, 231, 245, 0.9), rgba(241, 245, 251, 0.92));
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e6e6e6;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-thumb img {
|
||||
|
|
@ -113,9 +99,9 @@
|
|||
|
||||
.screen-saver-drawer .screen-saver-preview-card {
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 255, 0.98), rgba(255, 255, 255, 1));
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e6e6e6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.screen-saver-drawer .screen-saver-preview-stage {
|
||||
|
|
@ -125,17 +111,13 @@
|
|||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 8 / 5;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(140deg, rgba(11, 24, 48, 0.92), rgba(34, 59, 102, 0.88));
|
||||
border-radius: 4px;
|
||||
background: #f5f6fa;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.screen-saver-drawer .screen-saver-preview-stage::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.18));
|
||||
pointer-events: none;
|
||||
content: none;
|
||||
}
|
||||
|
||||
.screen-saver-drawer .screen-saver-preview-stage img {
|
||||
|
|
@ -146,7 +128,7 @@
|
|||
|
||||
.screen-saver-crop-modal .ant-modal-content {
|
||||
overflow: hidden;
|
||||
border-radius: 26px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.screen-saver-crop-modal .ant-modal-body {
|
||||
|
|
@ -165,26 +147,25 @@
|
|||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(22, 119, 255, 0.18), transparent 28%),
|
||||
linear-gradient(160deg, #081326, #12284b 55%, #17315b 100%);
|
||||
background: #f9fafe;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.screen-saver-crop-modal__stage-head {
|
||||
color: rgba(233, 242, 252, 0.92);
|
||||
color: #606775;
|
||||
}
|
||||
|
||||
.screen-saver-crop-modal__stage-head h3 {
|
||||
margin: 0 0 8px;
|
||||
color: #f8fbff;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
color: #333333;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.screen-saver-crop-modal__stage-head p {
|
||||
margin: 0;
|
||||
color: rgba(223, 233, 247, 0.72);
|
||||
color: #606775;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
|
|
@ -194,10 +175,10 @@
|
|||
aspect-ratio: 8 / 5;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(6, 13, 28, 0.78);
|
||||
box-shadow: 0 24px 50px rgba(5, 12, 25, 0.4);
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d8e3ff;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
|
@ -215,13 +196,13 @@
|
|||
}
|
||||
|
||||
.screen-saver-crop-modal__viewport::before {
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(60, 112, 245, 0.28);
|
||||
}
|
||||
|
||||
.screen-saver-crop-modal__viewport::after {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px);
|
||||
linear-gradient(rgba(60, 112, 245, 0.16) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(60, 112, 245, 0.16) 1px, transparent 1px);
|
||||
background-size: 33.333% 50%;
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
|
@ -242,7 +223,7 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: rgba(232, 241, 252, 0.72);
|
||||
color: #606775;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
|
@ -251,15 +232,15 @@
|
|||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 28px 24px;
|
||||
background: linear-gradient(180deg, #ffffff, #f7f9fd);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.screen-saver-crop-modal__sidebar-card {
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 18px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.06);
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.screen-saver-crop-modal__sidebar-card h4 {
|
||||
|
|
@ -297,27 +278,6 @@
|
|||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.screen-saver-page .screen-saver-table-card .ant-card-head {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-card .ant-card-head-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-card .ant-card-extra {
|
||||
margin-inline-start: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-card .ant-card-extra .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-table-wrap {
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.screen-saver-page .screen-saver-preview-stage {
|
||||
width: min(100%, 360px);
|
||||
max-height: 225px;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import {
|
|||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UploadProps } from "antd";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
|
|
@ -722,53 +724,72 @@ export default function ScreenSaverManagement() {
|
|||
|
||||
return (
|
||||
<PageContainer
|
||||
title="屏保管理"
|
||||
subtitle="管理平台级和用户级的屏保素材"
|
||||
headerExtra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增屏保
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
toolbar={
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索名称、描述、创建人或归属用户"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={scopeFilter}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => setScopeFilter(value)}
|
||||
options={[
|
||||
{ label: "全部作用域", value: "all" },
|
||||
{ label: "平台级", value: "PLATFORM" },
|
||||
{ label: "用户级", value: "USER" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => setStatusFilter(value)}
|
||||
options={[
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "已启用", value: "enabled" },
|
||||
{ label: "已停用", value: "disabled" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
title={null}
|
||||
className="screen-saver-page"
|
||||
>
|
||||
<div className="app-page__table-wrap screen-saver-table-wrap">
|
||||
<SectionCard
|
||||
title="屏保管理"
|
||||
description="管理平台级和用户级屏保素材,统一维护播放时长、启停状态和图片规格。"
|
||||
>
|
||||
<DataListPanel
|
||||
className="screen-saver-list-panel"
|
||||
leftActions={
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增屏保
|
||||
</Button>
|
||||
<Button icon={<SettingOutlined />} onClick={openSettingsModal}>
|
||||
播放设置
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索名称、描述、创建人或归属用户"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={scopeFilter}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => setScopeFilter(value)}
|
||||
options={[
|
||||
{ label: "全部作用域", value: "all" },
|
||||
{ label: "平台级", value: "PLATFORM" },
|
||||
{ label: "用户级", value: "USER" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => setStatusFilter(value)}
|
||||
options={[
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "已启用", value: "enabled" },
|
||||
{ label: "已停用", value: "disabled" },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading} title="刷新" aria-label="刷新" />
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className="screen-saver-table"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={pagedRecords}
|
||||
|
|
@ -779,16 +800,8 @@ export default function ScreenSaverManagement() {
|
|||
}}
|
||||
scroll={{ x: 1100, y: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredRecords.length}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
<Drawer
|
||||
title={editing ? "编辑屏保" : "新增屏保"}
|
||||
open={drawerOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,463 @@
|
|||
.speaker-reg-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.speaker-reg-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.speaker-reg-section-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.speaker-reg-layout {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(430px, 0.92fr) minmax(480px, 1.08fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.speaker-reg-card.ant-card {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-head {
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-head .ant-card-head-title {
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.speaker-reg-card-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b7cdfd;
|
||||
background: #f3f6ff;
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.speaker-reg-form .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.speaker-reg-tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.speaker-reg-tabs .ant-tabs-nav {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recording-area {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.script-box {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-left: 4px solid #3c70f5;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.script-box__label.ant-typography {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.script-box__content {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.record-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-record {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-record .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-record.idle {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.btn-record.idle:hover {
|
||||
background: #d9363e;
|
||||
}
|
||||
|
||||
.btn-record.recording {
|
||||
background: #3c70f5;
|
||||
}
|
||||
|
||||
.btn-record.recording:hover {
|
||||
background: #2458d9;
|
||||
}
|
||||
|
||||
.btn-record:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.record-progress {
|
||||
flex: 1;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-progress__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record-progress__head .is-recording {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.speaker-reg-upload,
|
||||
.speaker-reg-upload .ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-compact {
|
||||
width: 100%;
|
||||
min-height: 104px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed #b7cdfd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-compact:hover {
|
||||
border-color: #3c70f5;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.upload-compact__icon {
|
||||
margin-bottom: 8px;
|
||||
color: #3c70f5;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.upload-compact__title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.speaker-reg-audio-ready {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #b7cdfd;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.speaker-reg-audio-ready__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.speaker-reg-audio-ready__head .anticon {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.speaker-reg-audio-ready audio,
|
||||
.speaker-card audio {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.speaker-reg-submit-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.speaker-reg-submit-area .ant-btn {
|
||||
height: 34px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-strip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e6e6e6;
|
||||
background: #f9fafe;
|
||||
color: #596275;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.info-strip .anticon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: #3c70f5;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.speaker-reg-library > .ant-card-body {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.speaker-reg-library__tools .ant-input-search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.speaker-reg-library__tools .ant-badge-count {
|
||||
background: #3c70f5;
|
||||
}
|
||||
|
||||
.speaker-reg-library__hint.ant-typography {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px;
|
||||
color: #9095a1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.speaker-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.speaker-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.9fr) minmax(240px, 1.1fr) minmax(150px, 0.7fr);
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.speaker-card + .speaker-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.speaker-card:hover {
|
||||
border-color: #f0f0f0;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.speaker-card__head {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.speaker-card__identity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.speaker-card__identity > .ant-typography {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.speaker-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.speaker-card__meta .ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.speaker-card__meta .ant-typography {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.speaker-card__remark {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
margin-bottom: 0;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #596275;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.speaker-card > audio {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.speaker-card--no-remark > audio {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.speaker-card__footer {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 0;
|
||||
color: #9095a1;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.speaker-reg-empty {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.speaker-reg-library .app-pagination-container {
|
||||
flex-shrink: 0;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.speaker-reg-section-content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.speaker-reg-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.speaker-reg-card.ant-card {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.speaker-reg-card > .ant-card-head {
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-head .ant-card-head-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.speaker-reg-card > .ant-card-head .ant-card-extra,
|
||||
.speaker-reg-library__tools,
|
||||
.speaker-reg-library__tools .ant-input-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.record-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-progress {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.speaker-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.speaker-card__head,
|
||||
.speaker-card__remark,
|
||||
.speaker-card > audio,
|
||||
.speaker-card__footer,
|
||||
.speaker-card--no-remark > audio {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.speaker-card__footer {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,25 +5,25 @@ import {
|
|||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
FormOutlined,
|
||||
InfoCircleOutlined,
|
||||
SearchOutlined,
|
||||
StopOutlined,
|
||||
UploadOutlined,
|
||||
UserOutlined,
|
||||
ClockCircleOutlined,
|
||||
SoundOutlined,
|
||||
SafetyCertificateOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { Badge, Button, Col, Empty, Form, Input, List, Popconfirm, Progress, Row, Select, Spin, Space, Tabs, Tag, Typography, Upload, Tooltip, Divider, App } from 'antd';
|
||||
import { Badge, Button, Card, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Spin, Space, Tabs, Tag, Typography, Upload, Tooltip, App } from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { listUsers } from '../../api';
|
||||
import { deleteSpeaker, getSpeakerPage, registerSpeaker, SpeakerVO } from '../../api/business/speaker';
|
||||
import AppPagination from '../../components/shared/AppPagination';
|
||||
import PageContainer from '../../components/shared/PageContainer';
|
||||
import SectionCard from '../../components/shared/SectionCard';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import type { SysUser } from '../../types';
|
||||
import './SpeakerReg.css';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Text } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
const REG_CONTENT =
|
||||
|
|
@ -355,261 +355,56 @@ const SpeakerReg: React.FC = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="speaker-reg-container">
|
||||
<style>{`
|
||||
.speaker-reg-container {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
color: var(--app-text-main);
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
--accent-blue: var(--app-primary-color);
|
||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
||||
--glass-bg: var(--app-bg-card);
|
||||
--glass-border: 1px solid var(--app-border-color);
|
||||
}
|
||||
|
||||
.dashboard-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-left {
|
||||
flex: 1.1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 480px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-right {
|
||||
flex: 0.9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--glass-bg);
|
||||
border: var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, var(--accent-blue) 0%, #6366f1 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
border: var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--card-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px; /* Reduced from 24px to fit 1080p better */
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recording-area {
|
||||
background: var(--app-bg-surface-soft);
|
||||
border-radius: 12px;
|
||||
padding: 18px; /* Slightly tighter */
|
||||
border: 1px solid var(--app-border-color);
|
||||
}
|
||||
|
||||
.script-box {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--app-text-main);
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--accent-blue) 6%, transparent);
|
||||
border-left: 4px solid var(--accent-blue);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.record-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-record {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-record.idle {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.btn-record.idle:hover {
|
||||
transform: scale(1.05);
|
||||
background: #dc2626;
|
||||
}
|
||||
.btn-record.recording {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
animation: pulse-ring 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
|
||||
70% { box-shadow: 0 0 0 12px rgba(99, 102, 241, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
|
||||
}
|
||||
|
||||
.speaker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.speaker-card {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-bg-surface);
|
||||
border: 1px solid var(--app-border-color);
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.speaker-card:hover {
|
||||
border-color: var(--accent-blue);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.compact-form .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.upload-compact {
|
||||
border: 2px dashed var(--app-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.upload-compact:hover {
|
||||
border-color: var(--accent-blue);
|
||||
background: color-mix(in srgb, var(--accent-blue) 2%, transparent);
|
||||
}
|
||||
|
||||
.info-strip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background: color-mix(in srgb, var(--accent-blue) 8%, transparent);
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.custom-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.panel-left::-webkit-scrollbar,
|
||||
.speaker-list::-webkit-scrollbar,
|
||||
.glass-card::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
.panel-left::-webkit-scrollbar-thumb,
|
||||
.speaker-list::-webkit-scrollbar-thumb,
|
||||
.glass-card::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="header-section" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<h1 className="page-title">声纹采集工作台</h1>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>注册唯一的声纹特征,让系统识别您的专属声音</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Badge status="processing" text={<Text type="secondary" style={{ fontSize: 13 }}>声纹引擎就绪</Text>} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-layout">
|
||||
<div className="panel-left">
|
||||
<div className="glass-card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ background: 'var(--accent-blue)', width: 32, height: 32, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FormOutlined style={{ color: 'white', fontSize: 16 }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontWeight: 700, fontSize: 17 }}>{editingSpeaker ? '更新声纹档案' : '新建声纹档案'}</h3>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" className="compact-form">
|
||||
<PageContainer title={null} className="speaker-reg-page">
|
||||
<SectionCard
|
||||
title="声纹采集工作台"
|
||||
description="采集或上传声纹样本,并维护当前租户下的发言人声纹库。"
|
||||
extra={<Badge status="processing" text={<Text type="secondary">声纹引擎就绪</Text>} />}
|
||||
contentClassName="speaker-reg-section-content"
|
||||
>
|
||||
<div className="speaker-reg-layout">
|
||||
<Card
|
||||
className="speaker-reg-card speaker-reg-editor"
|
||||
title={
|
||||
<Space size={10}>
|
||||
<span className="speaker-reg-card-icon">
|
||||
<FormOutlined />
|
||||
</span>
|
||||
<span>{editingSpeaker ? '更新声纹档案' : '新建声纹档案'}</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="speaker-reg-form">
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Col xs={24} md={14}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={<Text strong>声纹名称</Text>}
|
||||
rules={[{ required: true, message: '必填' }]}
|
||||
>
|
||||
<Input size="middle" placeholder="姓名 / 职位 / 编号" style={{ borderRadius: 8 }} />
|
||||
<Input size="middle" placeholder="姓名 / 职位 / 编号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Col xs={24} md={10}>
|
||||
<Form.Item name="userId" label={<Text strong>绑定用户</Text>}>
|
||||
<Select
|
||||
size="middle"
|
||||
placeholder="系统关联"
|
||||
disabled={!isAdmin}
|
||||
style={{ borderRadius: 8 }}
|
||||
onChange={handleUserChange}
|
||||
options={userOptions.map(u => ({ label: u.displayName || u.username, value: u.userId }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="remark" label={<Text strong>备注 (可选)</Text>} style={{ marginTop: 16 }}>
|
||||
<Input size="middle" placeholder="记录使用场景或特征说明" style={{ borderRadius: 8 }} />
|
||||
<Form.Item name="remark" label={<Text strong>备注 (可选)</Text>}>
|
||||
<Input size="middle" placeholder="记录使用场景或特征说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
className="custom-tabs"
|
||||
className="speaker-reg-tabs"
|
||||
size="middle"
|
||||
items={[
|
||||
{
|
||||
|
|
@ -618,8 +413,8 @@ const SpeakerReg: React.FC = () => {
|
|||
children: (
|
||||
<div className="recording-area">
|
||||
<div className="script-box">
|
||||
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 6, opacity: 0.6 }}>录音文本内容:</Text>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{REG_CONTENT}</div>
|
||||
<Text strong className="script-box__label">录音文本内容:</Text>
|
||||
<div className="script-box__content">{REG_CONTENT}</div>
|
||||
</div>
|
||||
|
||||
<div className="record-controls">
|
||||
|
|
@ -630,18 +425,18 @@ const SpeakerReg: React.FC = () => {
|
|||
type="button"
|
||||
aria-label={recording ? '停止录制声纹样本' : '开始录制声纹样本'}
|
||||
>
|
||||
{recording ? <StopOutlined style={{ fontSize: 24 }} /> : <AudioOutlined style={{ fontSize: 24 }} />}
|
||||
{recording ? <StopOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
|
||||
<div style={{ flex: 1, maxWidth: 240 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text strong style={{ fontSize: 14, color: recording ? 'var(--accent-blue)' : undefined }}>{recording ? '正在采集声音...' : '等待录制'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{seconds}s / {DEFAULT_DURATION}s</Text>
|
||||
<div className="record-progress">
|
||||
<div className="record-progress__head">
|
||||
<Text strong className={recording ? 'is-recording' : ''}>{recording ? '正在采集声音...' : '等待录制'}</Text>
|
||||
<Text type="secondary">{seconds}s / {DEFAULT_DURATION}s</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={(seconds / DEFAULT_DURATION) * 100}
|
||||
showInfo={false}
|
||||
strokeColor="var(--accent-blue)"
|
||||
strokeColor="#3c70f5"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -653,11 +448,11 @@ const SpeakerReg: React.FC = () => {
|
|||
key: "2",
|
||||
label: <span><CloudUploadOutlined /> 离线文件上传</span>,
|
||||
children: (
|
||||
<Upload {...uploadProps} accept="audio/*" style={{ width: '100%' }}>
|
||||
<Upload {...uploadProps} accept="audio/*" className="speaker-reg-upload">
|
||||
<div className="upload-compact">
|
||||
<CloudUploadOutlined style={{ fontSize: 32, color: 'var(--accent-blue)', marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, marginBottom: 4 }}><Text strong>点击此处或将音频文件拖入</Text></div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>支持 MP3 / WAV / M4A,建议时长 5-15秒</Text>
|
||||
<CloudUploadOutlined className="upload-compact__icon" />
|
||||
<div className="upload-compact__title"><Text strong>点击此处或将音频文件拖入</Text></div>
|
||||
<Text type="secondary">支持 MP3 / WAV / M4A,建议时长 5-15 秒</Text>
|
||||
</div>
|
||||
</Upload>
|
||||
)
|
||||
|
|
@ -666,16 +461,16 @@ const SpeakerReg: React.FC = () => {
|
|||
/>
|
||||
|
||||
{audioUrl && (
|
||||
<div style={{ background: 'var(--app-bg-surface-soft)', padding: 12, borderRadius: 12, border: '1px solid var(--accent-blue)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong style={{ fontSize: 13 }}><CheckCircleOutlined style={{ color: 'var(--app-success-color)' }} /> 采样文件已就绪</Text>
|
||||
<div className="speaker-reg-audio-ready">
|
||||
<div className="speaker-reg-audio-ready__head">
|
||||
<Text strong><CheckCircleOutlined /> 采样文件已就绪</Text>
|
||||
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined />}>重新采集</Button>
|
||||
</div>
|
||||
<audio src={audioUrl} controls style={{ width: '100%', height: 32 }} />
|
||||
<audio src={audioUrl} controls />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="speaker-reg-submit-area">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
|
|
@ -683,73 +478,75 @@ const SpeakerReg: React.FC = () => {
|
|||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={recording || (!audioBlob && !editingSpeaker)}
|
||||
style={{ height: 48, borderRadius: 10, fontWeight: 600, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.2)' }}
|
||||
>
|
||||
{editingSpeaker ? '确认保存声纹变更' : '提交并同步到声纹库'}
|
||||
</Button>
|
||||
{editingSpeaker && (
|
||||
<Button size="middle" block onClick={resetFormState} style={{ height: 40, borderRadius: 10 }}>取消编辑</Button>
|
||||
<Button size="middle" block onClick={resetFormState}>取消编辑</Button>
|
||||
)}
|
||||
|
||||
<div className="info-strip">
|
||||
<SafetyCertificateOutlined style={{ color: 'var(--accent-blue)', fontSize: 16 }} />
|
||||
<div style={{ color: 'var(--app-text-secondary)', fontSize: 12 }}>
|
||||
<SafetyCertificateOutlined />
|
||||
<div>
|
||||
数据将加密存储,仅用于会议期间的发言人识别与角色分离。同租户内声纹名称需保持唯一。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="panel-right">
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--app-border-color)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 style={{ margin: 0, fontWeight: 700, fontSize: 17, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SoundOutlined style={{ color: 'var(--accent-blue)' }} />
|
||||
已注册声纹库
|
||||
</h3>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
按名称快速筛选当前声纹记录
|
||||
</Text>
|
||||
</div>
|
||||
<Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
onChange={e => {
|
||||
const nextValue = e.target.value;
|
||||
setSearchKeyword(nextValue);
|
||||
if (!nextValue.trim() && queryName) {
|
||||
setCurrent(1);
|
||||
setQueryName('');
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
placeholder="按名称搜索"
|
||||
style={{ width: 220, borderRadius: 8 }}
|
||||
/>
|
||||
<Badge count={total} overflowCount={999} style={{ backgroundColor: 'var(--accent-blue)' }} />
|
||||
</div>
|
||||
<Card
|
||||
className="speaker-reg-card speaker-reg-library"
|
||||
title={
|
||||
<Space size={8}>
|
||||
<SoundOutlined />
|
||||
<span>已注册声纹库</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap size={8} className="speaker-reg-library__tools">
|
||||
<Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
onChange={e => {
|
||||
const nextValue = e.target.value;
|
||||
setSearchKeyword(nextValue);
|
||||
if (!nextValue.trim() && queryName) {
|
||||
setCurrent(1);
|
||||
setQueryName('');
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
placeholder="按名称搜索"
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Badge count={total} overflowCount={999} />
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Text type="secondary" className="speaker-reg-library__hint">
|
||||
按名称快速筛选当前声纹记录,支持试听、编辑和删除。
|
||||
</Text>
|
||||
|
||||
<div className="speaker-list">
|
||||
<div className="speaker-list">
|
||||
<Spin spinning={listLoading}>
|
||||
{speakers.length === 0 ? (
|
||||
<Empty
|
||||
description={<Text type="secondary">{queryName ? '未找到匹配的声纹记录' : '暂无声纹记录'}</Text>}
|
||||
style={{ marginTop: 40 }}
|
||||
className="speaker-reg-empty"
|
||||
/>
|
||||
) : (
|
||||
speakers.map((s) => {
|
||||
const statusMeta = getSpeakerStatusMeta(s.status);
|
||||
return (
|
||||
<div className="speaker-card" key={s.id}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong style={{ fontSize: 15 }} ellipsis>{s.name}</Text>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4, alignItems: 'center' }}>
|
||||
<Tag color={statusMeta.color} bordered={false} style={{ margin: 0, fontSize: 11, borderRadius: 4 }}>
|
||||
<div className={s.remark ? "speaker-card" : "speaker-card speaker-card--no-remark"} key={s.id}>
|
||||
<div className="speaker-card__head">
|
||||
<div className="speaker-card__identity">
|
||||
<Text strong ellipsis>{s.name}</Text>
|
||||
<div className="speaker-card__meta">
|
||||
<Tag color={statusMeta.color} bordered={false}>
|
||||
{statusMeta.label}
|
||||
</Tag>
|
||||
{s.userId && <Text type="secondary" style={{ fontSize: 11 }}><UserOutlined /> ID:{s.userId}</Text>}
|
||||
{s.userId && <Text type="secondary"><UserOutlined /> ID:{s.userId}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
<Space size={0}>
|
||||
|
|
@ -763,17 +560,16 @@ const SpeakerReg: React.FC = () => {
|
|||
</div>
|
||||
|
||||
{s.remark && (
|
||||
<div style={{ fontSize: 12, opacity: 0.7, marginBottom: 10, background: 'var(--app-bg-surface-soft)', padding: '6px 10px', borderRadius: 6 }}>{s.remark}</div>
|
||||
<div className="speaker-card__remark">{s.remark}</div>
|
||||
)}
|
||||
|
||||
<audio
|
||||
src={buildResourceUrl(resourcePrefix, s.voicePath)}
|
||||
controls
|
||||
controlsList="nodownload"
|
||||
style={{ width: '100%', height: 28, marginTop: 4 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, fontSize: 11, opacity: 0.5 }}>
|
||||
<div className="speaker-card__footer">
|
||||
<span>更新于 {dayjs(s.updatedAt).format('YYYY-MM-DD HH:mm')}</span>
|
||||
<span>{((s.voiceSize || 0) / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
|
|
@ -782,8 +578,7 @@ const SpeakerReg: React.FC = () => {
|
|||
})
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
</div>
|
||||
<AppPagination
|
||||
variant="card"
|
||||
current={current}
|
||||
|
|
@ -794,10 +589,10 @@ const SpeakerReg: React.FC = () => {
|
|||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,139 +1,55 @@
|
|||
.devices-page {
|
||||
padding: 24px;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.devices-metrics {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.devices-metric-card {
|
||||
position: relative;
|
||||
.devices-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 14px 36px rgba(16, 24, 40, 0.08);
|
||||
min-height: 144px;
|
||||
|
||||
.ant-card-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.devices-metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.devices-metric-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -28px;
|
||||
top: -28px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.devices-metric-card--total::before {
|
||||
background: linear-gradient(135deg, #f7f9ff 0%, #eef4ff 100%);
|
||||
}
|
||||
|
||||
.devices-metric-card--online::before {
|
||||
background: linear-gradient(135deg, #f0fff8 0%, #dcfce7 100%);
|
||||
}
|
||||
|
||||
.devices-metric-card--enabled::before {
|
||||
background: linear-gradient(135deg, #fff8ed 0%, #ffedd5 100%);
|
||||
}
|
||||
|
||||
.devices-metric-card__icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
.devices-summary-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.devices-metric-card--total .devices-metric-card__icon {
|
||||
background: rgba(49, 110, 255, 0.12);
|
||||
color: #275df5;
|
||||
}
|
||||
|
||||
.devices-metric-card--online .devices-metric-card__icon {
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.devices-metric-card--enabled .devices-metric-card__icon {
|
||||
background: rgba(234, 88, 12, 0.12);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.devices-metric-card__content {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.devices-metric-card__label {
|
||||
.devices-summary-chip {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
background: #f9fafe;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.devices-summary-chip .anticon {
|
||||
color: #3c70f5;
|
||||
}
|
||||
|
||||
.devices-summary-chip strong {
|
||||
color: #1677ff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(15, 23, 42, 0.62);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.devices-metric-card__value {
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.devices-metric-card__hint {
|
||||
font-size: 13px;
|
||||
color: rgba(15, 23, 42, 0.68);
|
||||
}
|
||||
|
||||
.devices-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.devices-title {
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.devices-table-card {
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.devices-table-toolbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.devices-search-input {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.device-icon-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #f1f5ff 0%, #e0e7ff 100%);
|
||||
border-radius: 12px;
|
||||
background: #eef4ff;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -182,18 +98,11 @@
|
|||
|
||||
@media (max-width: 768px) {
|
||||
.devices-page {
|
||||
padding: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.devices-metric-card {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.devices-metric-card .ant-card-body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.devices-metric-card__value {
|
||||
font-size: 30px;
|
||||
.devices-summary-chips,
|
||||
.devices-summary-chip {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Tag, Typography, message } from "antd";
|
||||
import { Button, Drawer, Form, Input, Popconfirm, Select, Space, Tag, Typography, message } from "antd";
|
||||
import { CheckCircleOutlined, DeleteOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, ReloadOutlined, SearchOutlined, ThunderboltOutlined, UndoOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deleteManagedDevice, kickManagedDevice, listManagedDevices, resetManagedDeviceStats, updateManagedDevice } from "@/api";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import type { DeviceInfo } from "@/types";
|
||||
|
|
@ -286,83 +287,66 @@ export default function Devices() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("devices.title")}
|
||||
subtitle={t("devices.subtitle")}
|
||||
headerExtra={
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData}>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
}
|
||||
toolbar={
|
||||
<Input
|
||||
placeholder={t("devicesExt.searchPlaceholder")}
|
||||
prefix={<SearchOutlined aria-hidden="true" />}
|
||||
style={{ width: 420 }}
|
||||
value={searchText}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
allowClear
|
||||
aria-label={t("devicesExt.searchLabel")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]} className="devices-metrics">
|
||||
<Col xs={24} md={8}>
|
||||
<Card className="devices-metric-card devices-metric-card--total" bordered={false}>
|
||||
<div className="devices-metric-card__icon">
|
||||
<DesktopOutlined aria-hidden="true" />
|
||||
<PageContainer title={null} className="devices-page">
|
||||
<SectionCard
|
||||
title={t("devices.title")}
|
||||
description={t("devices.subtitle")}
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
<div className="devices-summary-chips" aria-label={t("devices.title")}>
|
||||
<span className="devices-summary-chip">
|
||||
<DesktopOutlined aria-hidden="true" />
|
||||
<span>{t("devicesExt.totalDevices")}</span>
|
||||
<strong className="tabular-nums">{stats.total}</strong>
|
||||
</span>
|
||||
<span className="devices-summary-chip">
|
||||
<ThunderboltOutlined aria-hidden="true" />
|
||||
<span>{t("devicesExt.onlineDevices")}</span>
|
||||
<strong className="tabular-nums">{stats.online}</strong>
|
||||
</span>
|
||||
<span className="devices-summary-chip">
|
||||
<CheckCircleOutlined aria-hidden="true" />
|
||||
<span>{t("devicesExt.enabledDevices")}</span>
|
||||
<strong className="tabular-nums">{stats.enabled}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="devices-metric-card__content">
|
||||
<div className="devices-metric-card__label">{t("devicesExt.totalDevices")}</div>
|
||||
<div className="devices-metric-card__value tabular-nums">{stats.total}</div>
|
||||
<div className="devices-metric-card__hint">{t("devicesExt.totalDevicesHint")}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card className="devices-metric-card devices-metric-card--online" bordered={false}>
|
||||
<div className="devices-metric-card__icon">
|
||||
<ThunderboltOutlined aria-hidden="true" />
|
||||
</div>
|
||||
<div className="devices-metric-card__content">
|
||||
<div className="devices-metric-card__label">{t("devicesExt.onlineDevices")}</div>
|
||||
<div className="devices-metric-card__value tabular-nums">{stats.online}</div>
|
||||
<div className="devices-metric-card__hint">{t("devicesExt.onlineDevicesHint")}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card className="devices-metric-card devices-metric-card--enabled" bordered={false}>
|
||||
<div className="devices-metric-card__icon">
|
||||
<CheckCircleOutlined aria-hidden="true" />
|
||||
</div>
|
||||
<div className="devices-metric-card__content">
|
||||
<div className="devices-metric-card__label">{t("devicesExt.enabledDevices")}</div>
|
||||
<div className="devices-metric-card__value tabular-nums">{stats.enabled}</div>
|
||||
<div className="devices-metric-card__hint">{t("devicesExt.enabledDevicesHint")}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="app-page__content-card" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }} style={{ flex: 1, minHeight: 0, marginTop: 16 }}>
|
||||
<div className="app-page__table-wrap" style={{flex: 1, minHeight: 0, overflow: "hidden", padding: "0 24px"}}>
|
||||
}
|
||||
rightActions={
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder={t("devicesExt.searchPlaceholder")}
|
||||
prefix={<SearchOutlined aria-hidden="true" />}
|
||||
style={{ width: 360 }}
|
||||
value={searchText}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
allowClear
|
||||
aria-label={t("devicesExt.searchLabel")}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData}>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={filteredData.length}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable<DeviceInfo>
|
||||
rowKey="deviceId"
|
||||
dataSource={pagedData}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
scroll={{y: "calc(100vh - 520px)", x: 1980}}
|
||||
scroll={{y: "100%", x: 1980}}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={filteredData.length}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</Card>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
.orgs-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.orgs-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.orgs-table-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.orgs-toolbar {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
.orgs-table-shell .ant-table-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { Button, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApartmentOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
|
||||
import { createOrg, deleteOrg, listOrgs, listTenants, updateOrg } from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import type { OrgNode, SysOrg, SysTenant } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -160,31 +161,39 @@ export default function Orgs() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("orgs.title")}
|
||||
subtitle={t("orgs.subtitle")}
|
||||
headerExtra={
|
||||
can("sys:org:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
isPlatformMode && (
|
||||
<Space>
|
||||
<Text strong>{t("users.tenant")}</Text>
|
||||
<Select style={{ width: 220 }} placeholder={t("orgs.selectTenant")} value={selectedTenantId} onChange={setSelectedTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />
|
||||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>{t("common.refresh")}</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{selectedTenantId !== undefined ? (
|
||||
<Table rowKey="id" columns={columns} dataSource={treeData} loading={loading} pagination={false} size="middle" scroll={{ y: "calc(100vh - 350px)" }} expandable={{ defaultExpandAllRows: true }} />
|
||||
) : (
|
||||
<div className="py-20 flex justify-center"><Empty description={t("orgs.selectTenant")} /></div>
|
||||
)}
|
||||
<PageContainer title={null} className="orgs-page">
|
||||
<SectionCard
|
||||
title={t("orgs.title")}
|
||||
description={t("orgs.subtitle")}
|
||||
>
|
||||
<div className="app-page__content-toolbar orgs-toolbar">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
{can("sys:org:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters">
|
||||
<Space wrap>
|
||||
{isPlatformMode ? (
|
||||
<>
|
||||
<Text strong>{t("users.tenant")}</Text>
|
||||
<Select style={{ width: 220 }} placeholder={t("orgs.selectTenant")} value={selectedTenantId} onChange={setSelectedTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />
|
||||
</>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>{t("common.refresh")}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div className="orgs-table-shell">
|
||||
{selectedTenantId !== undefined ? (
|
||||
<Table rowKey="id" columns={columns} dataSource={treeData} loading={loading} pagination={false} size="middle" scroll={{ y: "100%" }} expandable={{ defaultExpandAllRows: true }} />
|
||||
) : (
|
||||
<div className="app-page__empty-state"><Empty description={t("orgs.selectTenant")} /></div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
.tenants-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.tenants-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tenants-list-panel .data-list-panel__table-area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tenants-list-panel .data-list-panel__table-area .app-page__table-wrap {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tenants-grid-wrap {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 8px 4px 0;
|
||||
}
|
||||
|
||||
.tenants-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tenants-grid-wrap > .app-page__empty-state {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tenant-card {
|
||||
height: 100%;
|
||||
min-height: 230px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e6e6e6 !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tenant-card .ant-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tenant-card .ant-card-actions {
|
||||
border-top-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.tenant-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tenant-card__avatar {
|
||||
margin-right: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tenant-card__avatar--enabled {
|
||||
color: #1677ff;
|
||||
background-color: #e6f4ff;
|
||||
}
|
||||
|
||||
.tenant-card__avatar--disabled {
|
||||
color: #ff4d4f;
|
||||
background-color: #fff1f0;
|
||||
}
|
||||
|
||||
.tenant-card__header-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tenant-card__title-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tenant-card__title {
|
||||
min-width: 0;
|
||||
margin: 0 !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.tenant-card__status {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tenant-card__code {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tenant-card__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tenant-card__meta-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tenant-card__meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.tenant-card__meta-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 8px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.tenant-card__divider {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.tenant-card__remark {
|
||||
height: 36px;
|
||||
margin: 0 !important;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tenant-card__action--edit {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.tenant-card__action--delete {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.tenants-search-form {
|
||||
justify-content: flex-end;
|
||||
gap: 8px 0;
|
||||
}
|
||||
|
||||
.tenants-search-form .ant-form-item {
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.tenants-search-form__name {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.tenants-search-form__code {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tenants-search-form,
|
||||
.tenants-search-form .ant-form-item,
|
||||
.tenants-search-form .ant-input-affix-wrapper,
|
||||
.tenants-search-form .ant-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
import { Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Pagination, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { createTenant, deleteTenant, getPlatformRuntime, listTenants, updateTenant } from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
|
||||
import type { PlatformRuntime, SysTenant } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
|
|
@ -121,14 +123,12 @@ export default function Tenants() {
|
|||
const statusItem = statusDict.find((dictItem) => dictItem.itemValue === String(item.status));
|
||||
|
||||
return (
|
||||
<List.Item style={{ height: '100%' }}>
|
||||
<Card
|
||||
key={item.id}
|
||||
hoverable
|
||||
className="tenant-card shadow-sm border-0"
|
||||
style={{ borderRadius: "12px", overflow: "hidden", height: "100%", display: 'flex', flexDirection: 'column' }}
|
||||
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column' } }}
|
||||
className="tenant-card"
|
||||
actions={[
|
||||
can("sys_tenant:update") && <Tooltip title={t("common.edit")} key="edit-tip"><EditOutlined key="edit" onClick={() => openEdit(item)} style={{ color: "#1677ff" }} /></Tooltip>,
|
||||
can("sys_tenant:update") && <Tooltip title={t("common.edit")} key="edit-tip"><EditOutlined key="edit" onClick={() => openEdit(item)} className="tenant-card__action tenant-card__action--edit" /></Tooltip>,
|
||||
runtime?.tenantMode !== "single" && can("sys_tenant:delete") && (
|
||||
<Popconfirm
|
||||
key="delete-pop"
|
||||
|
|
@ -137,98 +137,109 @@ export default function Tenants() {
|
|||
cancelText={t("common.cancel")}
|
||||
onConfirm={() => handleDelete(item.id)}
|
||||
>
|
||||
<DeleteOutlined key="delete" style={{ color: "#ff4d4f" }} />
|
||||
<DeleteOutlined key="delete" className="tenant-card__action tenant-card__action--delete" />
|
||||
</Popconfirm>
|
||||
)
|
||||
].filter(Boolean) as React.ReactNode[]}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: 16 }}>
|
||||
<div className="tenant-card__header">
|
||||
<Avatar
|
||||
size={48}
|
||||
icon={<ShopOutlined />}
|
||||
style={{ backgroundColor: item.status === 1 ? "#e6f4ff" : "#fff1f0", color: item.status === 1 ? "#1677ff" : "#ff4d4f", marginRight: 12, borderRadius: "8px" }}
|
||||
className={`tenant-card__avatar ${item.status === 1 ? "tenant-card__avatar--enabled" : "tenant-card__avatar--disabled"}`}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={5} style={{ margin: 0, fontSize: "16px" }} ellipsis={{ tooltip: item.tenantName }}>{item.tenantName}</Title>
|
||||
<Tag color={item.status === 1 ? "green" : "red"} style={{ margin: 0, borderRadius: "4px" }}>
|
||||
<div className="tenant-card__header-main">
|
||||
<div className="tenant-card__title-row">
|
||||
<Title level={5} className="tenant-card__title" ellipsis={{ tooltip: item.tenantName }}>{item.tenantName}</Title>
|
||||
<Tag color={item.status === 1 ? "green" : "red"} className="tenant-card__status">
|
||||
{statusItem ? statusItem.itemLabel : item.status === 1 ? "Enabled" : "Disabled"}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">CODE: {item.tenantCode}</Text>
|
||||
<Text type="secondary" className="tenant-card__code tabular-nums">CODE: {item.tenantCode}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-content" style={{ fontSize: "13px", flex: 1 }}>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", color: "#595959" }}>
|
||||
<UserOutlined style={{ marginRight: 8, color: "#bfbfbf" }} />
|
||||
<div className="tenant-card__content">
|
||||
<Space direction="vertical" size={8} className="tenant-card__meta-list">
|
||||
<div className="tenant-card__meta">
|
||||
<UserOutlined className="tenant-card__meta-icon" />
|
||||
<Text ellipsis={{ tooltip: item.contactName || "-" }}>{item.contactName || "-"}</Text>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", color: "#595959" }}>
|
||||
<PhoneOutlined style={{ marginRight: 8, color: "#bfbfbf" }} />
|
||||
<div className="tenant-card__meta">
|
||||
<PhoneOutlined className="tenant-card__meta-icon" />
|
||||
<Text className="tabular-nums">{item.contactPhone || "-"}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
{item.remark && (
|
||||
<>
|
||||
<Divider style={{ margin: "12px 0" }} />
|
||||
<Paragraph ellipsis={{ rows: 2, tooltip: item.remark }} style={{ margin: 0, fontSize: "12px", color: "#8c8c8c", height: "36px" }}>
|
||||
<Divider className="tenant-card__divider" />
|
||||
<Paragraph ellipsis={{ rows: 2, tooltip: item.remark }} className="tenant-card__remark">
|
||||
{item.remark}
|
||||
</Paragraph>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("tenants.title")}
|
||||
subtitle={t("tenants.subtitle")}
|
||||
headerExtra={
|
||||
runtime?.tenantMode !== "single" && can("sys_tenant:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
<Form form={searchForm} layout="inline" onFinish={handleSearch}>
|
||||
<Form.Item name="name">
|
||||
<Input placeholder={t("tenants.tenantName")} prefix={<SearchOutlined />} allowClear style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="code">
|
||||
<Input placeholder={t("tenants.tenantCode")} allowClear style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">{t("common.search")}</Button>
|
||||
<Button onClick={handleReset}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
>
|
||||
<List
|
||||
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
renderItem={renderTenantCard}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
{...getStandardPagination(total, queryParams.current, queryParams.size, handlePageChange, { variant: "card" })}
|
||||
className="app-global-pagination"
|
||||
style={{
|
||||
marginTop: 24,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
/>
|
||||
<PageContainer title={null} className="tenants-page">
|
||||
<SectionCard
|
||||
title={t("tenants.title")}
|
||||
description={t("tenants.subtitle")}
|
||||
>
|
||||
<DataListPanel
|
||||
className="tenants-list-panel"
|
||||
leftActions={
|
||||
runtime?.tenantMode !== "single" && can("sys_tenant:create") ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
rightActions={
|
||||
<Form form={searchForm} layout="inline" onFinish={handleSearch} className="tenants-search-form">
|
||||
<Form.Item name="name">
|
||||
<Input placeholder={t("tenants.tenantName")} prefix={<SearchOutlined />} allowClear className="tenants-search-form__name" />
|
||||
</Form.Item>
|
||||
<Form.Item name="code">
|
||||
<Input placeholder={t("tenants.tenantCode")} allowClear className="tenants-search-form__code" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">{t("common.search")}</Button>
|
||||
<Button onClick={handleReset}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
variant="card"
|
||||
current={queryParams.current}
|
||||
pageSize={queryParams.size}
|
||||
total={total}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="tenants-grid-wrap">
|
||||
{loading ? (
|
||||
<div className="tenants-grid">
|
||||
<Card loading className="tenant-card" />
|
||||
</div>
|
||||
) : data.length > 0 ? (
|
||||
<div className="tenants-grid">
|
||||
{data.map(renderTenantCard)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-page__empty-state">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer
|
||||
title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
.avatar-crop-modal .ant-modal-content {
|
||||
overflow: hidden;
|
||||
border-radius: 26px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.avatar-crop-modal .ant-modal-body {
|
||||
|
|
@ -20,28 +20,27 @@
|
|||
align-items: center;
|
||||
gap: 30px;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(22, 119, 255, 0.18), transparent 28%),
|
||||
linear-gradient(160deg, #081326, #12284b 55%, #17315b 100%);
|
||||
background: #f9fafe;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.avatar-crop-modal__stage-head {
|
||||
width: 100%;
|
||||
color: rgba(233, 242, 252, 0.92);
|
||||
color: #606775;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-crop-modal__stage-head h3 {
|
||||
margin: 0 0 8px;
|
||||
color: #f8fbff;
|
||||
color: #333333;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.avatar-crop-modal__stage-head p {
|
||||
margin: 0;
|
||||
color: rgba(223, 233, 247, 0.72);
|
||||
color: #606775;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
|
@ -51,8 +50,9 @@
|
|||
width: 300px;
|
||||
height: 300px;
|
||||
border-radius: 50%;
|
||||
background: rgba(6, 13, 28, 0.78);
|
||||
box-shadow: 0 24px 50px rgba(5, 12, 25, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
border: 1px solid #d8e3ff;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(60, 112, 245, 0.28);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
|
|
@ -85,12 +85,12 @@
|
|||
.avatar-crop-modal__sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.avatar-crop-modal__sidebar-card {
|
||||
padding: 32px 28px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.avatar-crop-modal__sidebar-card h4 {
|
||||
|
|
@ -114,5 +114,5 @@
|
|||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.04);
|
||||
border-top: 1px solid #e6e6e6;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,44 @@
|
|||
.profile-page {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.profile-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.profile-section-content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-side-col,
|
||||
.profile-main-col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-side-stack {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-panel-card.ant-card {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.profile-panel-card.ant-card .ant-card-body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.profile-summary-card,
|
||||
.profile-help-card,
|
||||
.profile-tabs-card {
|
||||
|
|
@ -26,8 +59,8 @@
|
|||
}
|
||||
|
||||
.profile-summary-card__avatar.ant-avatar {
|
||||
background: linear-gradient(135deg, #1677ff 0%, #57a7ff 100%);
|
||||
box-shadow: 0 16px 28px rgba(22, 119, 255, 0.22);
|
||||
background: #3c70f5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.profile-summary-card__identity {
|
||||
|
|
@ -47,11 +80,11 @@
|
|||
.profile-summary-card__meta {
|
||||
margin-top: 16px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.profile-summary-card__meta .ant-descriptions-view {
|
||||
border-radius: 14px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +107,7 @@
|
|||
|
||||
.profile-help-card__alert {
|
||||
margin-top: 16px;
|
||||
border-radius: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.profile-tabs-card .ant-card-body {
|
||||
|
|
@ -83,13 +116,13 @@
|
|||
|
||||
.profile-tabs .ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95) 0%, rgba(255, 255, 255, 0.75) 100%);
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.profile-tabs .ant-tabs-content-holder {
|
||||
padding: 24px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.profile-form .ant-form-item {
|
||||
|
|
@ -102,9 +135,9 @@
|
|||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(59, 130, 246, 0.25);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #b7cdfd;
|
||||
background: #f9fafe;
|
||||
}
|
||||
|
||||
.profile-upload-panel__hint {
|
||||
|
|
@ -119,7 +152,7 @@
|
|||
}
|
||||
|
||||
.profile-security-alert {
|
||||
border-radius: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.profile-credential-panel {
|
||||
|
|
@ -138,7 +171,7 @@
|
|||
}
|
||||
|
||||
.profile-credential-panel__descriptions .ant-descriptions-view {
|
||||
border-radius: 14px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ import {useEffect, useMemo, useState} from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth";
|
||||
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import type { BotCredential, UserProfile } from "@/types";
|
||||
import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password";
|
||||
import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog";
|
||||
|
|
@ -173,13 +174,16 @@ export default function Profile() {
|
|||
const userStatus = user ? (user.status === 0 ? <Tag color="red">禁用</Tag> : <Tag color="green">启用</Tag>) : "-";
|
||||
|
||||
return (
|
||||
<div className="app-page app-page--contained profile-page">
|
||||
<PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
|
||||
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} xl={8}>
|
||||
<Space direction="vertical" size={16} className="profile-side-stack">
|
||||
<Card className="app-page__panel-card profile-summary-card" loading={loading}>
|
||||
<PageContainer title={null} className="profile-page">
|
||||
<SectionCard
|
||||
title={t("profile.title")}
|
||||
description={t("profile.subtitle")}
|
||||
contentClassName="profile-section-content"
|
||||
>
|
||||
<Row gutter={[16, 16]} className="profile-layout">
|
||||
<Col xs={24} xl={8} className="profile-side-col">
|
||||
<Space direction="vertical" size={12} className="profile-side-stack">
|
||||
<Card className="profile-panel-card profile-summary-card" loading={loading}>
|
||||
<div className="profile-summary-card__header">
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}
|
||||
disabled={avatarUploading}>
|
||||
|
|
@ -208,7 +212,7 @@ export default function Profile() {
|
|||
{user && !user.isPlatformAdmin && !user.isTenantAdmin ?
|
||||
<Tag color="blue">{t("profile.standardUser")}</Tag> : null}
|
||||
{user && !user.isPlatformAdmin && user.hasPlatformAdminPrivilege ?
|
||||
<Tag color="purple">可切换平台管理员</Tag> : null}
|
||||
<Tag color="geekblue">可切换平台管理员</Tag> : null}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
|
@ -230,13 +234,13 @@ export default function Profile() {
|
|||
{
|
||||
key: "status",
|
||||
label: t("common.status"),
|
||||
children: userStatus
|
||||
children: userStatus
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<Card className="app-page__panel-card profile-help-card">
|
||||
<Card className="profile-panel-card profile-help-card">
|
||||
<Title level={5} className="profile-help-card__title">
|
||||
安全提示
|
||||
</Title>
|
||||
|
|
@ -254,13 +258,13 @@ export default function Profile() {
|
|||
className="profile-help-card__alert"
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={16}>
|
||||
<Card className="app-page__content-card profile-tabs-card">
|
||||
<Tabs
|
||||
<Col xs={24} xl={16} className="profile-main-col">
|
||||
<Card className="profile-panel-card profile-tabs-card">
|
||||
<Tabs
|
||||
defaultActiveKey="basic"
|
||||
className="profile-tabs"
|
||||
items={[
|
||||
|
|
@ -315,10 +319,13 @@ export default function Profile() {
|
|||
</Upload>
|
||||
</div>
|
||||
|
||||
<div className="app-page__page-actions profile-tab-actions">
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
|
||||
{t("profile.saveChanges")}
|
||||
</Button>
|
||||
<div className="app-page__content-toolbar profile-tab-actions">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
|
||||
{t("profile.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters" />
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
|
|
@ -394,10 +401,13 @@ export default function Profile() {
|
|||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<div className="app-page__page-actions profile-tab-actions">
|
||||
<Button type="primary" danger loading={saving} onClick={() => pwdForm.submit()}>
|
||||
{t("profile.updatePassword")}
|
||||
</Button>
|
||||
<div className="app-page__content-toolbar profile-tab-actions">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
<Button type="primary" danger loading={saving} onClick={() => pwdForm.submit()}>
|
||||
{t("profile.updatePassword")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters" />
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
|
|
@ -467,30 +477,34 @@ export default function Profile() {
|
|||
/>
|
||||
</Card>
|
||||
|
||||
<div className="app-page__page-actions profile-tab-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={credential?.bound ? <ReloadOutlined/> : <KeyOutlined/>}
|
||||
loading={credentialSaving}
|
||||
onClick={handleGenerateCredential}
|
||||
>
|
||||
{credential?.bound ? t("profile.regenerateBotCredential") : t("profile.generateBotCredential")}
|
||||
</Button>
|
||||
<div className="app-page__content-toolbar profile-tab-actions">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={credential?.bound ? <ReloadOutlined/> : <KeyOutlined/>}
|
||||
loading={credentialSaving}
|
||||
onClick={handleGenerateCredential}
|
||||
>
|
||||
{credential?.bound ? t("profile.regenerateBotCredential") : t("profile.generateBotCredential")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</SectionCard>
|
||||
|
||||
<AvatarCropDialog
|
||||
state={cropState}
|
||||
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
|
||||
onConfirm={handleUploadCroppedImage}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
.dictionaries-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.dictionaries-page > .ant-row {
|
||||
.dictionaries-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dictionaries-layout {
|
||||
flex: 1 1 0;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
|
|
@ -12,7 +20,7 @@
|
|||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dictionaries-page > .ant-row > .ant-col {
|
||||
.dictionaries-layout > .ant-col {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
|
|
@ -20,14 +28,14 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dictionaries-page > .ant-row > .ant-col > .app-page__panel-card {
|
||||
.dictionaries-layout > .ant-col > .app-page__panel-card {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dictionaries-page > .ant-row > .ant-col > .app-page__panel-card > .ant-card-body {
|
||||
.dictionaries-layout > .ant-col > .app-page__panel-card > .ant-card-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
|
@ -70,10 +78,6 @@
|
|||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.dict-type-row:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dict-type-row-selected {
|
||||
background-color: #e6f7ff !important;
|
||||
border-right: 3px solid #1890ff;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { BookOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ProfileOutlin
|
|||
import { createDictItem, createDictType, deleteDictItem, deleteDictType, fetchDictItems, fetchDictTypes, updateDictItem, updateDictType } from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysDictItem, SysDictType } from "@/types";
|
||||
import "./index.less";
|
||||
|
|
@ -163,10 +164,12 @@ export default function Dictionaries() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="app-page dictionaries-page">
|
||||
<PageHeader title={t("dicts.title")} subtitle={t("dicts.subtitle")}/>
|
||||
|
||||
<Row gutter={24} className="flex-1 min-h-0 overflow-hidden">
|
||||
<PageContainer title={null} className="dictionaries-page">
|
||||
<SectionCard
|
||||
title={t("dicts.title")}
|
||||
description={t("dicts.subtitle")}
|
||||
>
|
||||
<Row gutter={16} className="dictionaries-layout">
|
||||
<Col span={8} className="h-full flex flex-col overflow-hidden">
|
||||
<Card title={<Space><BookOutlined aria-hidden="true"/><span>{t("dicts.dictType")}</span></Space>}
|
||||
className="app-page__panel-card flex-1 flex flex-col overflow-hidden" styles={{
|
||||
|
|
@ -274,6 +277,7 @@ export default function Dictionaries() {
|
|||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer title={<Space><BookOutlined
|
||||
aria-hidden="true"/><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>}
|
||||
|
|
@ -311,6 +315,6 @@ export default function Dictionaries() {
|
|||
<Form.Item label={t("common.remark")} name="remark"><Input.TextArea placeholder={t("dictsExt.itemRemarkPlaceholder")} rows={3} /></Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,13 @@
|
|||
.logs-tabs {
|
||||
padding: 12px 24px 0;
|
||||
|
||||
.ant-tabs-nav {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before,
|
||||
.ant-tabs-ink-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-tabs-tab + .ant-tabs-tab {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logs-tab-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 86px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #d9e6ff;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.72);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.ant-tabs-tab:hover .logs-tab-button {
|
||||
color: var(--app-primary-color, #1677ff);
|
||||
border-color: #91caff;
|
||||
background: rgba(22, 119, 255, 0.04);
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active .logs-tab-button {
|
||||
color: var(--app-primary-color, #1677ff);
|
||||
border-color: var(--app-primary-color, #1677ff);
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.14);
|
||||
}
|
||||
.logs-page {
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.logs-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { Button, Card, DatePicker, Descriptions, Input, Modal, Popconfirm, Select, Space, Tabs, Tag, Typography, message } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, DatePicker, Descriptions, Input, Modal, Popconfirm, Select, Space, Tabs, Tag, Typography, message } from "antd";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteOutlined, EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { DeleteOutlined, EyeOutlined, ReloadOutlined, SearchOutlined, ShopOutlined } from "@ant-design/icons";
|
||||
import { cleanLogs, fetchLogModules, fetchLogs, listTenants } from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import type { SysLog, UserProfile } from "@/types";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import type { SysLog, SysTenant, UserProfile } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
|
@ -25,6 +26,7 @@ export default function Logs() {
|
|||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState<SysLog | null>(null);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const requestSeqRef = useRef(0);
|
||||
const [params, setParams] = useState({
|
||||
current: 1,
|
||||
size: 20,
|
||||
|
|
@ -62,14 +64,34 @@ export default function Logs() {
|
|||
return activeTab === "OPERATION" ? t("logs.opLog") : t("logs.loginLog");
|
||||
}, [activeTab, logTypeDict, t]);
|
||||
|
||||
const renderLogTab = (type: string, label: string) => (
|
||||
<span className="logs-tab-button">
|
||||
{type === "OPERATION" ? <InfoCircleOutlined aria-hidden="true" /> : <UserOutlined aria-hidden="true" />}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
const tabItems = logTypeDict.length > 0
|
||||
? logTypeDict.map((item) => ({
|
||||
key: item.itemValue,
|
||||
label: item.itemLabel,
|
||||
}))
|
||||
: [
|
||||
{ key: "OPERATION", label: t("logs.opLog") },
|
||||
{ key: "LOGIN", label: t("logs.loginLog") },
|
||||
];
|
||||
|
||||
const cleanAction = (
|
||||
<Popconfirm
|
||||
title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })}
|
||||
description={isPlatformAdmin ? t("logsExt.cleanConfirmDescriptionWithTenant", { tenant: activeTenantName }) : t("logsExt.cleanConfirmDescription")}
|
||||
okText={t("common.confirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
okButtonProps={{ danger: true, loading: cleaning }}
|
||||
onConfirm={() => void handleClean()}
|
||||
>
|
||||
<Button danger className="logs-clean-action" icon={<DeleteOutlined aria-hidden="true" />} loading={cleaning}>
|
||||
{t("logsExt.cleanCurrent", { type: activeLogTypeLabel })}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
|
||||
const loadData = async (currentParams = params) => {
|
||||
const requestSeq = requestSeqRef.current + 1;
|
||||
requestSeqRef.current = requestSeq;
|
||||
setLoading(true);
|
||||
try {
|
||||
const apiParams = {
|
||||
|
|
@ -77,10 +99,15 @@ export default function Logs() {
|
|||
sortOrder: currentParams.sortOrder === "ascend" ? "asc" : currentParams.sortOrder === "descend" ? "desc" : undefined
|
||||
};
|
||||
const result = await fetchLogs({...apiParams, logType: activeTab});
|
||||
if (requestSeq !== requestSeqRef.current) {
|
||||
return;
|
||||
}
|
||||
setData(result.records || []);
|
||||
setTotal(result.total || 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestSeq === requestSeqRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -222,7 +249,19 @@ export default function Logs() {
|
|||
key: "action",
|
||||
width: 60,
|
||||
fixed: "right" as const,
|
||||
render: (_: any, record: SysLog) => <Button type="text" size="small" icon={<EyeOutlined aria-hidden="true" />} onClick={() => { setSelectedLog(record); setDetailModalVisible(true); }} aria-label={t("common.view")} />
|
||||
render: (_: any, record: SysLog) => (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined aria-hidden="true" />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedLog(record);
|
||||
setDetailModalVisible(true);
|
||||
}}
|
||||
aria-label={t("common.view")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -260,128 +299,103 @@ export default function Logs() {
|
|||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("logs.title")}
|
||||
subtitle={t("logs.subtitle")}
|
||||
headerExtra={
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })}
|
||||
description={isPlatformAdmin ? t("logsExt.cleanConfirmDescriptionWithTenant", { tenant: activeTenantName }) : t("logsExt.cleanConfirmDescription")}
|
||||
okText={t("common.confirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
okButtonProps={{ danger: true, loading: cleaning }}
|
||||
onConfirm={() => void handleClean()}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined aria-hidden="true" />} loading={cleaning}>
|
||||
{t("logsExt.cleanCurrent", { type: activeLogTypeLabel })}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}
|
||||
toolbar={
|
||||
<Space wrap size="middle">
|
||||
<Input
|
||||
placeholder={t("logs.searchPlaceholder")}
|
||||
style={{ width: 180 }}
|
||||
value={params.operation}
|
||||
onChange={(event) => setParams({ ...params, operation: event.target.value })}
|
||||
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
|
||||
allowClear
|
||||
<PageContainer title={null} className="logs-page">
|
||||
<SectionCard
|
||||
title={t("logs.title")}
|
||||
description={t("logs.subtitle")}
|
||||
tabs={
|
||||
<Tabs
|
||||
className="logs-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => {
|
||||
setActiveTab(key);
|
||||
setParams((prev) => ({ ...prev, current: 1, moduleName: "" }));
|
||||
}}
|
||||
items={tabItems}
|
||||
size="middle"
|
||||
type="card"
|
||||
/>
|
||||
{isPlatformAdmin && (
|
||||
<Select
|
||||
placeholder={t("users.tenantFilter")}
|
||||
style={{ width: 200 }}
|
||||
value={params.tenantId}
|
||||
onChange={(value) => setParams({ ...params, tenantId: value, moduleName: "", current: 1 })}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
{activeTab === "OPERATION" && (
|
||||
<Select
|
||||
placeholder={t("logsExt.filterModule")}
|
||||
style={{ width: 160 }}
|
||||
value={params.moduleName || undefined}
|
||||
onChange={(value) => setParams({ ...params, moduleName: value || "" })}
|
||||
options={moduleOptions.map((item) => ({ label: item, value: item }))}
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
placeholder={t("common.status")}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
value={params.status}
|
||||
onChange={(value) => setParams({ ...params, status: value })}
|
||||
options={logStatusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))}
|
||||
aria-label={t("common.status")}
|
||||
/>
|
||||
<RangePicker
|
||||
onChange={(dates) =>
|
||||
setParams({
|
||||
...params,
|
||||
startDate: dates ? dates[0]?.format("YYYY-MM-DD") || "" : "",
|
||||
endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : ""
|
||||
})
|
||||
}
|
||||
aria-label="Filter date range"
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
|
||||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<Tabs
|
||||
className="logs-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => { setActiveTab(key); setParams((prev) => ({ ...prev, current: 1, moduleName: "" })); }}
|
||||
size="large"
|
||||
tabBarExtraContent={(
|
||||
<Popconfirm
|
||||
title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })}
|
||||
description={isPlatformAdmin ? t("logsExt.cleanConfirmDescriptionWithTenant", { tenant: activeTenantName }) : t("logsExt.cleanConfirmDescription")}
|
||||
okText={t("common.confirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
okButtonProps={{ danger: true, loading: cleaning }}
|
||||
onConfirm={() => void handleClean()}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined aria-hidden="true" />} loading={cleaning}>
|
||||
{t("logsExt.cleanCurrent", { type: activeLogTypeLabel })}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{logTypeDict.length > 0
|
||||
? logTypeDict.map((item) => <Tabs.TabPane tab={renderLogTab(item.itemValue, item.itemLabel)} key={item.itemValue} />)
|
||||
: <><Tabs.TabPane tab={renderLogTab("OPERATION", t("logs.opLog"))} key="OPERATION" /><Tabs.TabPane tab={renderLogTab("LOGIN", t("logs.loginLog"))} key="LOGIN" /></>}
|
||||
</Tabs>
|
||||
|
||||
<div className="app-page__table-wrap" style={{flex: 1, minHeight: 0, overflow: "hidden", padding: "0 24px"}}>
|
||||
<ListTable
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
totalCount={total}
|
||||
scroll={{ y: "calc(100vh - 460px)" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppPagination
|
||||
current={params.current}
|
||||
pageSize={params.size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
setParams({ ...params, current: page, size: pageSize });
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<DataListPanel
|
||||
leftActions={cleanAction}
|
||||
rightActions={
|
||||
<Space wrap size={8}>
|
||||
<Input
|
||||
placeholder={t("logs.searchPlaceholder")}
|
||||
style={{ width: 180 }}
|
||||
value={params.operation}
|
||||
onChange={(event) => setParams({ ...params, operation: event.target.value })}
|
||||
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
|
||||
allowClear
|
||||
/>
|
||||
{isPlatformAdmin && (
|
||||
<Select
|
||||
placeholder={t("users.tenantFilter")}
|
||||
style={{ width: 200 }}
|
||||
value={params.tenantId}
|
||||
onChange={(value) => setParams({ ...params, tenantId: value, moduleName: "", current: 1 })}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
{activeTab === "OPERATION" && (
|
||||
<Select
|
||||
placeholder={t("logsExt.filterModule")}
|
||||
style={{ width: 160 }}
|
||||
value={params.moduleName || undefined}
|
||||
onChange={(value) => setParams({ ...params, moduleName: value || "" })}
|
||||
options={moduleOptions.map((item) => ({ label: item, value: item }))}
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
placeholder={t("common.status")}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
value={params.status}
|
||||
onChange={(value) => setParams({ ...params, status: value })}
|
||||
options={logStatusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))}
|
||||
aria-label={t("common.status")}
|
||||
/>
|
||||
<RangePicker
|
||||
onChange={(dates) =>
|
||||
setParams({
|
||||
...params,
|
||||
startDate: dates ? dates[0]?.format("YYYY-MM-DD") || "" : "",
|
||||
endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : ""
|
||||
})
|
||||
}
|
||||
aria-label="Filter date range"
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
|
||||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={params.current}
|
||||
pageSize={params.size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
setParams({ ...params, current: page, size: pageSize });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
totalCount={total}
|
||||
scroll={{ y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Modal title={t("logs.detailTitle")} open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}>{t("logsExt.close")}</Button>]} width={700}>
|
||||
{selectedLog && (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
.platform-settings-page {
|
||||
max-width: 1280px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.platform-settings-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.platform-settings-scroll {
|
||||
|
|
@ -10,7 +17,11 @@
|
|||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 4px 32px 0;
|
||||
padding: 8px 4px 24px;
|
||||
}
|
||||
|
||||
.platform-settings-toolbar {
|
||||
margin: 0 4px 12px;
|
||||
}
|
||||
|
||||
.platform-settings-form {
|
||||
|
|
@ -21,6 +32,12 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
.platform-settings-page .ant-card {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.platform-settings-page .ant-row {
|
||||
row-gap: 24px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { FileTextOutlined, GlobalOutlined, PictureOutlined, SaveOutlined, Upload
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAdminPlatformConfig, updatePlatformConfig, uploadPlatformAsset } from "@/api";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import type { SysPlatformConfig } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
|
|
@ -73,18 +73,23 @@ export default function PlatformSettings() {
|
|||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("platformSettings.title")}
|
||||
subtitle={t("platformSettings.subtitle")}
|
||||
headerExtra={
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => form.submit()}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="platform-settings-scroll">
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} initialValues={{ projectName: "UnisBase" }} className="platform-settings-form">
|
||||
<Row gutter={24}>
|
||||
<PageContainer title={null} className="platform-settings-page">
|
||||
<SectionCard
|
||||
title={t("platformSettings.title")}
|
||||
description={t("platformSettings.subtitle")}
|
||||
layout="auto"
|
||||
>
|
||||
<div className="platform-settings-scroll">
|
||||
<div className="app-page__content-toolbar platform-settings-toolbar">
|
||||
<div className="app-page__content-toolbar-actions">
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => form.submit()}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-page__content-toolbar-filters" />
|
||||
</div>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} initialValues={{ projectName: "UnisBase" }} className="platform-settings-form">
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Card title={<><GlobalOutlined className="mr-2" />{t("platformSettings.basicInfo")}</>} className="mb-6" loading={loading}>
|
||||
<Form.Item label={t("platformSettings.projectName")} name="projectName" rules={[
|
||||
|
|
@ -152,9 +157,10 @@ export default function PlatformSettings() {
|
|||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
.sys-params-page {
|
||||
min-height: 100%;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.sys-params-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
.sys-params-page > .page-container__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sys-params-table-card {
|
||||
border-radius: 8px;
|
||||
.sys-params-search-form {
|
||||
justify-content: flex-end;
|
||||
gap: 8px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sys-params-table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sys-params-search-input {
|
||||
border-radius: 6px;
|
||||
.sys-params-search-form .ant-form-item {
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.param-key-text {
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
.ant-segmented-item-selected {
|
||||
background: var(--app-primary-color) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.35);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ant-segmented-item {
|
||||
|
|
@ -49,3 +49,12 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sys-params-search-form,
|
||||
.sys-params-search-form .ant-form-item,
|
||||
.sys-params-search-form .ant-input-affix-wrapper,
|
||||
.sys-params-search-form .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Segmented, Select, Space, Switch, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { Button, Col, Drawer, Form, Input, Popconfirm, Row, Segmented, Select, Space, Switch, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import { createParam, deleteParam, pageParams, updateParam } from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import type { SysParamQuery, SysParamVO } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
|
|
@ -252,52 +253,56 @@ export default function SysParams() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("sysParams.title")}
|
||||
subtitle={t("sysParams.subtitle")}
|
||||
headerExtra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
}
|
||||
toolbar={
|
||||
<Form form={searchForm} layout="inline" onFinish={handleSearch}>
|
||||
<Form.Item name="paramKey">
|
||||
<Input placeholder={t("sysParams.paramKey")} prefix={<SearchOutlined />} allowClear style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="paramType">
|
||||
<Select placeholder={t("sysParams.paramType")} allowClear style={{ width: 150 }} options={paramTypeDict.map((item) => ({ label: item.itemLabel, value: item.itemValue }))} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">{t("common.search")}</Button>
|
||||
<Button onClick={handleReset}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
>
|
||||
<Card className="app-page__content-card">
|
||||
<div className="app-page__table-wrap">
|
||||
<ListTable
|
||||
rowKey="paramId"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ y: "calc(100vh - 350px)" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppPagination
|
||||
current={queryParams.pageNum || 1}
|
||||
pageSize={queryParams.pageSize || 10}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
handlePageChange(page, pageSize);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<PageContainer title={null} className="sys-params-page">
|
||||
<SectionCard
|
||||
title={t("sysParams.title")}
|
||||
description={t("sysParams.subtitle")}
|
||||
>
|
||||
<DataListPanel
|
||||
leftActions={
|
||||
can("sys_param:create") ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
rightActions={
|
||||
<Form form={searchForm} layout="inline" onFinish={handleSearch} className="sys-params-search-form">
|
||||
<Form.Item name="paramKey">
|
||||
<Input placeholder={t("sysParams.paramKey")} prefix={<SearchOutlined />} allowClear style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="paramType">
|
||||
<Select placeholder={t("sysParams.paramType")} allowClear style={{ width: 150 }} options={paramTypeDict.map((item) => ({ label: item.itemLabel, value: item.itemValue }))} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">{t("common.search")}</Button>
|
||||
<Button onClick={handleReset}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
footer={
|
||||
<AppPagination
|
||||
current={queryParams.pageNum || 1}
|
||||
pageSize={queryParams.pageSize || 10}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
handlePageChange(page, pageSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTable
|
||||
rowKey="paramId"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ y: "100%" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</DataListPanel>
|
||||
</SectionCard>
|
||||
|
||||
<Drawer
|
||||
title={<Space size={8}><SettingOutlined />{editing ? t("sysParams.drawerTitleEdit") : t("sysParams.drawerTitleCreate")}</Space>}
|
||||
|
|
|
|||
Loading…
Reference in New Issue