feat:前端项目页面重构

dev_na
puz 2026-06-30 13:37:33 +08:00
parent 6c970536b2
commit d5525496ea
69 changed files with 6259 additions and 3787 deletions

View File

@ -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;
}
}

View File

@ -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)" }}> mp3wavm4a {createConfig.offlineAudioMaxSizeMb}MB</p>
<p className="ant-upload-drag-icon"><CloudUploadOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> mp3wavm4a {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>

View File

@ -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 {

View File

@ -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,

View File

@ -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;

View File

@ -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%;
}
}

View File

@ -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

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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,

View File

@ -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;
}

View File

@ -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>}

View File

@ -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;
}

View File

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

View File

@ -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 {

View File

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

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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">

View File

@ -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;
}

View File

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

View File

@ -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;
}

View File

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

View File

@ -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;
}
}

View File

@ -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={

View File

@ -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%;
}
}

View File

@ -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">

View File

@ -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%;
}
}

View File

@ -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">

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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%;
}
}

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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

View File

@ -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>

View File

@ -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;
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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)}>

View File

@ -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;
}
}

View File

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

View File

@ -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;

View File

@ -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}

View File

@ -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;
}
}

View File

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

View File

@ -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%;
}
}

View File

@ -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={

View File

@ -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;
}

View File

@ -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">

View File

@ -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;
}
}

View File

@ -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>}

View File

@ -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;
}

View File

@ -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;
}

View File

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

View File

@ -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;

View File

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

View File

@ -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;
}

View File

@ -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 && (

View File

@ -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;
}

View File

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

View File

@ -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;
}
}

View File

@ -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>}