fix: improve enterprise template and signature layouts

This commit is contained in:
hectorzhao
2026-07-13 10:31:25 +08:00
parent d4b2df82da
commit 19d47b45eb
6 changed files with 248 additions and 38 deletions
@@ -423,6 +423,8 @@
- 企业应用新增时选择企业必须使用通用下拉控件和真实企业接口;下拉控件的视觉、尺寸、禁用态、错误态应与系统内其他 Select 保持一致。
- 所有企业和企业应用下拉控件必须支持按名称搜索;企业应用管理列表不展示 AppID。
- 企业短信模板列表必须采用自适应布局,在常用桌面及平板视口下无需水平滚动即可看到编辑、删除等操作。
- 运营端“企业模板管理”以响应式列表行展示模板、归属、内容、状态和更新时间;预览、编辑、删除操作在常用视口内始终可见,不得依赖水平滚动。
- 运营端“企业签名管理”的移动、联通、电信报备状态必须将状态文字与通过数/总数允许分行展示;报备详情、报备状态、编辑、删除四个操作按钮在空间不足时按每行两个排列,不得将按钮文字挤成单字换行。
### 5.12 运营端审核
+2
View File
@@ -3207,6 +3207,8 @@ npm run verify:phase8
| TC-MOCK-CLEAN-002 | 访问运营端数据统计、账务账户、发送监控、安全控制、手机号段库、报备字段库、通道组、报备任务、报备记录。 | 所有列表和卡片来自真实 API;新增动作写入数据库;后端缺失的编辑/删除能力不得用本地状态伪造。 |
| TC-UI-ENTERPRISE-SELECT-001 | 逐一打开包含企业或企业应用选择的表单和筛选项,输入部分企业/应用名称。 | 下拉面板提供搜索框并实时缩小真实 API 选项范围;清空后恢复全部选项。 |
| TC-UI-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开企业短信模板页。 | 模板卡片自适应换列,页面不出现水平滚动,每张卡片的编辑和删除按钮直接可见。 |
| TC-ADMIN-ENTERPRISE-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开运营端“企业模板管理”,查看长企业名、长模板内容和包含多变量的真实记录。 | 列表行按视口自适应重排,无水平滚动;预览、编辑、删除始终可见且可操作,内容摘要不撑破容器。 |
| TC-ADMIN-ENTERPRISE-SIGNATURE-LAYOUT-001 | 在运营端“企业签名管理”打开移动/联通/电信显示“未报备(0/2)”的真实签名,分别使用 1024px 和 1366px 视口。 | 状态标签和数量可分行但各自保持完整;四个操作按钮按两列两行排列,文字不被挤成单字换行,卡片不产生水平滚动。 |
| TC-ADMIN-REPORT-FIELD-CODE-001 | 在报备字段库分别提交 `License2026`、`license_code`、中文和空白代码,并直接调用真实新增 API 复验。 | 只有 `License2026` 写入 PostgreSQL;前端阻止非法值,API 同样返回 400,不依赖前端校验。 |
| TC-ADMIN-CHANNEL-REPORT-SIGNATURE-001 | 打开包含数据库签名 `【安徽航天信息】` 的通道报备详情及签名详情弹窗。 | 两处均只显示单层 `【安徽航天信息】`,不出现重复中括号。 |
| TC-MOCK-CLEAN-003 | 运营端创建企业、编辑企业、禁用/启用企业、删除企业,再刷新页面和重新登录客户端。 | Tenant 状态持久化;列表刷新后状态不丢;禁用/删除企业阻断客户端业务访问;动作写系统日志。 |
+7
View File
@@ -1659,3 +1659,10 @@ git diff --check
- 报备字段代码在前端和 NestJS API 双层限制为 `A-Z/a-z/0-9`;API 会拒绝下划线、中文、空白等非法代码,防止绕过页面写入 PostgreSQL。
- 运营端浏览器标题更新为“聆界短信管理平台”。通道报备详情和签名详情弹窗在展示前剔除数据中已存在的外层中/英文括号,统一只渲染一层中括号。
- 本地验证:API 全量 13 suites、137 项通过,API build、前端 build 和 `git diff --check` 通过;前端仅有既有 chunk size warning。应用内浏览器确认本地运营端登录路由、非空 DOM、无框架错误覆盖,且标题为“聆界短信管理平台”;因本地未启动真实 API,验证码请求返回 502,无法进入登录后页面完成模板响应式、下拉搜索和签名实数据的视觉点击验收,未注入 mock 或绕过认证。截图阶段应用内浏览器页面挂载超时,未将截图标记为通过。
## 2026-07-13 运营端企业模板与企业签名布局优化
- 确认上一次自适应修正落在客户端 `/client/templates`,而运营端 `/admin/enterprise-templates` 仍使用累计最小宽度约 1820px 的表格,操作列必须水平滚动才能看到。
- 企业模板管理改为响应式列表行:将模板名称/审核状态、企业/应用/签名归属、两行内容摘要/变量数、更新时间和操作分区展示。在 1180px 以下重排为三列,860px 以下变为单列,预览/编辑/删除始终保留在当前视口。分页仍基于真实 API 结果,每页 10 条。
- 企业签名管理将三网报备标签与 `approved/total` 数量拆开,容器可换行但文字和数量各自保持完整;报备详情/报备状态/编辑/删除固定为两列两行。同时修正签名摘要网格少一列导致“引流信息”和操作区被挤到下一行的布局问题。
- 本地验证:API 全量 13 suites、137 项通过,API build、前端 build、`git diff --check` 通过;前端仅有既有 chunk size warning。Chrome 当前无已登录生产标签,部署前未绕过图形验证码或注入 mock 数据。
@@ -82,12 +82,14 @@ type CarrierReportSummary = { status: string; approved: number; total: number };
function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral"></Tag>;
if (summary.status === 'approved') return <Tag tone="success">{summary.approved}/{summary.total}</Tag>;
if (summary.status === 'failed' || summary.status === 'rejected') return <Tag tone="danger">{summary.approved}/{summary.total}</Tag>;
if (summary.status === 'waiting_material') return <Tag tone="warning">{summary.approved}/{summary.total}</Tag>;
if (summary.approved > 0) return <Tag tone="info">{summary.approved}/{summary.total}</Tag>;
if (summary.status === 'reporting' || summary.status === 'exporting') return <Tag tone="info">0/{summary.total}</Tag>;
return <Tag tone="neutral">0/{summary.total}</Tag>;
let label = '未报备';
let tone: 'success' | 'danger' | 'warning' | 'info' | 'neutral' = 'neutral';
if (summary.status === 'approved') { label = '全部通过'; tone = 'success'; }
else if (summary.status === 'failed' || summary.status === 'rejected') { label = '报备失败'; tone = 'danger'; }
else if (summary.status === 'waiting_material') { label = '资料待补充'; tone = 'warning'; }
else if (summary.approved > 0) { label = '部分通过'; tone = 'info'; }
else if (summary.status === 'reporting' || summary.status === 'exporting') { label = '报备中'; tone = 'info'; }
return <span className="carrier-report-summary"><Tag tone={tone}>{label}</Tag><small>{summary.approved}/{summary.total}</small></span>;
}
function AuditStatusTag({ status }: { status: string }) {
+54 -26
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
type TemplateFormState = {
tenantId: string;
@@ -259,6 +259,7 @@ export function AdminEnterpriseTemplatesPage() {
const [templateKeyword, setTemplateKeyword] = useState('');
const [appliedTemplateKeyword, setAppliedTemplateKeyword] = useState('');
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, templateKeyword: appliedTemplateKeyword }) {
try {
@@ -288,6 +289,14 @@ export function AdminEnterpriseTemplatesPage() {
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedTemplateKeyword || item.name.includes(appliedTemplateKeyword) || item.content.includes(appliedTemplateKeyword) || application.includes(appliedTemplateKeyword));
}), [appliedEnterpriseKeyword, appliedTemplateKeyword, templates]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [appliedEnterpriseKeyword, appliedTemplateKeyword]);
async function saveTemplate(state: TemplateFormState) {
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
@@ -328,30 +337,6 @@ export function AdminEnterpriseTemplatesPage() {
await loadData();
}
const columns: Array<TableColumn<ClientSmsTemplate>> = [
{ key: 'name', title: '模板名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
{ key: 'tenant', title: '企业', width: '240px', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? record.applicationId },
{ key: 'signature', title: '签名', width: '160px', render: (record) => record.signature?.name ?? '-' },
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <span className="table-long-text table-long-text--sms-template">{record.content}</span> },
{ key: 'variables', title: '变量', width: '120px', render: (record) => `${record.variables?.length ?? 0}` },
{ key: 'status', title: '审核状态', width: '130px', render: (record) => <Tag tone={statusTone(record.auditStatus)}>{auditStatusLabel[record.auditStatus] ?? record.auditStatus}</Tag> },
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (record) => formatDate(record.updatedAt) },
{
key: 'actions',
title: '操作',
align: 'right',
width: '220px',
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(record)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(record)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger"></Button>
</div>
),
},
];
return (
<section className="page-stack admin-customer-split-page">
<div className="page-heading">
@@ -387,7 +372,50 @@ export function AdminEnterpriseTemplatesPage() {
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
value={activeTab}
items={[
{ label: '短信模板', value: 'sms', content: <Table columns={columns} data={filteredTemplates} emptyText="暂无企业模板" rowKey="id" /> },
{ label: '短信模板', value: 'sms', content: (
<div className="admin-enterprise-template-list">
{visibleTemplates.map((template) => (
<article className="admin-enterprise-template-row" key={template.id}>
<div className="admin-enterprise-template-row__identity">
<div className="admin-enterprise-template-row__title">
<strong>{template.name}</strong>
<Tag tone={statusTone(template.auditStatus)}>{auditStatusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
</div>
<dl>
<div><dt></dt><dd>{template.tenant?.name ?? template.tenantId}</dd></div>
<div><dt></dt><dd>{template.application?.name ?? template.applicationId}</dd></div>
<div><dt></dt><dd>{template.signature?.name ?? '未绑定'}</dd></div>
</dl>
</div>
<div className="admin-enterprise-template-row__content">
<span></span>
<p>{template.content}</p>
<small>{template.variables?.length ?? 0} </small>
</div>
<div className="admin-enterprise-template-row__meta">
<span></span>
<strong>{formatDate(template.updatedAt)}</strong>
</div>
<div className="admin-enterprise-template-row__actions">
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(template)} size="sm" variant="danger"></Button>
</div>
</article>
))}
{filteredTemplates.length === 0 ? <div className="ui-table__empty"></div> : null}
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredTemplates.length}
totalPages={totalPages}
/>
</div>
) },
{ label: '彩信模板', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> },
]}
/>
+175 -6
View File
@@ -2817,25 +2817,194 @@ h3 {
.admin-enterprise-signature-list .signature-summary {
gap: var(--space-2);
grid-template-columns: 22px minmax(96px, 0.9fr) minmax(132px, 1fr) minmax(104px, 0.85fr) repeat(4, minmax(64px, 0.55fr)) minmax(206px, auto);
min-width: 1040px;
grid-template-columns: 22px minmax(104px, 0.85fr) minmax(132px, 1fr) minmax(104px, 0.8fr) repeat(5, minmax(76px, 0.55fr)) 184px;
min-width: 0;
padding-inline: var(--space-3);
}
.admin-enterprise-signature-list .signature-actions {
display: flex;
display: grid;
gap: 6px;
justify-content: flex-end;
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-self: end;
min-width: 206px;
white-space: nowrap;
min-width: 184px;
width: 184px;
}
.admin-enterprise-signature-list .signature-actions .ui-button {
justify-content: center;
min-width: 0;
padding-left: 8px;
padding-right: 8px;
}
.carrier-report-summary {
align-items: flex-start;
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.carrier-report-summary small {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 24px;
white-space: nowrap;
}
.admin-enterprise-template-list {
display: grid;
gap: var(--space-3);
}
.admin-enterprise-template-row {
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(220px, 1.15fr) minmax(280px, 1.6fr) minmax(136px, 0.65fr) 188px;
min-width: 0;
padding: var(--space-5);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.admin-enterprise-template-row:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm);
}
.admin-enterprise-template-row__identity,
.admin-enterprise-template-row__content,
.admin-enterprise-template-row__meta {
min-width: 0;
}
.admin-enterprise-template-row__title {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.admin-enterprise-template-row__title > strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.admin-enterprise-template-row dl {
display: grid;
gap: 5px;
margin: var(--space-3) 0 0;
}
.admin-enterprise-template-row dl div {
display: grid;
gap: var(--space-2);
grid-template-columns: 42px minmax(0, 1fr);
}
.admin-enterprise-template-row dt,
.admin-enterprise-template-row__content > span,
.admin-enterprise-template-row__meta > span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.admin-enterprise-template-row dd {
color: var(--color-text-strong);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-enterprise-template-row__content p {
color: var(--color-text-strong);
display: -webkit-box;
line-height: 1.65;
margin: var(--space-2) 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.admin-enterprise-template-row__content small {
color: var(--color-text-muted);
}
.admin-enterprise-template-row__meta {
display: grid;
gap: var(--space-2);
}
.admin-enterprise-template-row__meta strong {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.admin-enterprise-template-row__actions {
display: grid;
gap: var(--space-2);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-enterprise-template-row__actions .ui-button {
justify-content: center;
min-width: 0;
}
@media (max-width: 1180px) {
.admin-enterprise-template-row {
grid-template-columns: minmax(210px, 1fr) minmax(260px, 1.35fr) 168px;
}
.admin-enterprise-template-row__meta {
grid-column: 1;
}
.admin-enterprise-template-row__actions {
grid-column: 3;
grid-row: 1 / span 2;
}
.admin-enterprise-signature-list .signature-summary {
grid-template-columns: 22px repeat(7, minmax(76px, 1fr)) 184px;
}
.admin-enterprise-signature-list .signature-summary > div:nth-of-type(3) {
display: none;
}
}
@media (max-width: 860px) {
.admin-enterprise-template-row {
align-items: stretch;
grid-template-columns: 1fr;
}
.admin-enterprise-template-row__meta,
.admin-enterprise-template-row__actions {
grid-column: auto;
grid-row: auto;
}
.admin-enterprise-signature-list .signature-summary {
grid-template-columns: 22px repeat(2, minmax(0, 1fr));
}
.admin-enterprise-signature-list .signature-summary > div,
.admin-enterprise-signature-list .signature-summary > div:nth-of-type(3) {
display: grid;
}
.admin-enterprise-signature-list .signature-actions {
grid-column: 2 / -1;
justify-self: stretch;
width: 100%;
}
}
.admin-split-filter {
align-items: end;
display: grid;