2 Commits

10 changed files with 316 additions and 13 deletions

View File

@@ -15,8 +15,10 @@ export default function Dashboard() {
todayCount: 0, todayCount: 0,
trend: [0,0,0,0,0,0,0], trend: [0,0,0,0,0,0,0],
trendLabels: ['','','','','','',''], trendLabels: ['','','','','','',''],
trendFullDates: ['','','','','','',''],
maxTrend: 1 maxTrend: 1
}); });
const [tooltip, setTooltip] = useState<{ visible: boolean; x: number; y: number; date: string; count: number }>({ visible: false, x: 0, y: 0, date: '', count: 0 });
const [currentUser, setCurrentUser] = useState<User | null>(null); const [currentUser, setCurrentUser] = useState<User | null>(null);
const [timeRange, setTimeRange] = useState<'7days' | '1month'>('7days'); const [timeRange, setTimeRange] = useState<'7days' | '1month'>('7days');
@@ -49,12 +51,14 @@ export default function Dashboard() {
const daysCount = timeRange === '7days' ? 7 : 30; const daysCount = timeRange === '7days' ? 7 : 30;
const trend: number[] = []; const trend: number[] = [];
const labels: string[] = []; const labels: string[] = [];
const fullDates: string[] = [];
for (let i = daysCount - 1; i >= 0; i--) { for (let i = daysCount - 1; i >= 0; i--) {
const d = new Date(now); const d = new Date(now);
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0]; const dateStr = d.toISOString().split('T')[0];
const label = timeRange === '7days' ? `${d.getMonth() + 1}/${d.getDate()}` : `${d.getDate()}`; const label = timeRange === '7days' ? `${d.getMonth() + 1}/${d.getDate()}` : `${d.getDate()}`;
labels.push(label); labels.push(label);
fullDates.push(dateStr);
trend.push(userReports.filter(r => r.createdAt === dateStr).length); trend.push(userReports.filter(r => r.createdAt === dateStr).length);
} }
const maxTrend = Math.max(...trend, 1); const maxTrend = Math.max(...trend, 1);
@@ -67,6 +71,7 @@ export default function Dashboard() {
todayCount: todayReports.length, todayCount: todayReports.length,
trend, trend,
trendLabels: labels, trendLabels: labels,
trendFullDates: fullDates,
maxTrend maxTrend
}); });
}, [navigate, timeRange]); }, [navigate, timeRange]);
@@ -131,7 +136,31 @@ export default function Dashboard() {
</div> </div>
<div className="flex-1 bg-slate-50 rounded-xl p-6 min-h-[240px] relative"> <div className="flex-1 bg-slate-50 rounded-xl p-6 min-h-[240px] relative">
{/* SVG Area Chart */} {/* SVG Area Chart */}
<svg viewBox="0 0 300 135" className="w-full h-full overflow-visible"> <svg
viewBox="0 0 300 135"
className="w-full h-full overflow-visible"
onMouseMove={(e) => {
const svg = e.currentTarget;
const rect = svg.getBoundingClientRect();
const mouseX = ((e.clientX - rect.left) / rect.width) * 300;
const paddingX = 10;
const chartW = 300 - paddingX * 2;
const n = stats.trend.length;
if (n <= 1) return;
let idx = Math.round(((mouseX - paddingX) / chartW) * (n - 1));
idx = Math.max(0, Math.min(n - 1, idx));
const ptX = paddingX + (idx / (n - 1)) * chartW;
const ptY = 8 + (120 - 16) - (stats.maxTrend > 0 ? (stats.trend[idx] / stats.maxTrend) * (120 - 16) : 0);
setTooltip({
visible: true,
x: (ptX / 300) * rect.width,
y: (ptY / 135) * rect.height,
date: stats.trendFullDates[idx] || '',
count: stats.trend[idx]
});
}}
onMouseLeave={() => setTooltip(prev => ({ ...prev, visible: false }))}
>
<defs> <defs>
<linearGradient id="trendGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="trendGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity="0.35" /> <stop offset="0%" stopColor="#2563EB" stopOpacity="0.35" />
@@ -168,17 +197,37 @@ export default function Dashboard() {
<g> <g>
<path d={areaPath} fill="url(#trendGradient)" /> <path d={areaPath} fill="url(#trendGradient)" />
<path d={linePath} fill="none" stroke="#2563EB" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d={linePath} fill="none" stroke="#2563EB" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
{/* Transparent capture layer for reliable mouse events */}
<rect x="0" y="0" width="300" height="135" fill="transparent" />
{points.map((p, i) => ( {points.map((p, i) => (
<g key={i}> <g key={i}>
<circle cx={p.x} cy={p.y} r="3.5" fill="#2563EB" stroke="#fff" strokeWidth="2" /> {/* 7天模式显示圆点和数值30天模式隐藏 */}
<text x={p.x} y={p.y - 10} textAnchor="middle" fontSize="8" fill="#64748B" fontWeight="bold">{p.count}</text> {stats.trend.length <= 10 && (
<text x={p.x} y={128} textAnchor="middle" fontSize={stats.trendLabels.length > 10 ? '7' : '8'} fill="#94A3B8" fontWeight="bold">{p.label}</text> <>
<circle cx={p.x} cy={p.y} r="3.5" fill="#2563EB" stroke="#fff" strokeWidth="2" />
<text x={p.x} y={p.y - 10} textAnchor="middle" fontSize="8" fill="#64748B" fontWeight="bold">{p.count}</text>
</>
)}
{/* 标签稀疏化7天每天显示30天每隔5天显示 */}
{(stats.trend.length <= 10 || i % 5 === 0) && (
<text x={p.x} y={128} textAnchor="middle" fontSize={stats.trendLabels.length > 10 ? '7' : '8'} fill="#94A3B8" fontWeight="bold">{p.label}</text>
)}
</g> </g>
))} ))}
</g> </g>
); );
})()} })()}
</svg> </svg>
{/* Tooltip */}
{tooltip.visible && (
<div
className="absolute pointer-events-none bg-slate-800 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10"
style={{ left: tooltip.x, top: tooltip.y - 40, transform: 'translateX(-50%)' }}
>
<div className="font-bold">{tooltip.date}</div>
<div className="text-slate-300">: {tooltip.count}</div>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -63,14 +63,9 @@ export default function Login() {
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings); const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
if (!settingsRaw.frameCount) { if (!settingsRaw.frameCount) {
const round1 = (n: number) => Math.round(n * 10) / 10;
const positions: number[] = [];
for (let i = 1; i <= 12; i++) {
positions.push(round1((100 / 13) * i));
}
const defaultSettings = { const defaultSettings = {
frameCount: 12, frameCount: 6,
framePositions: positions, framePositions: [1, 3, 5, 7, 9, 11],
apiEndpoint: '', apiEndpoint: '',
apiKey: '', apiKey: '',
defaultTemplate: savedTemplates[0]?.id || '', defaultTemplate: savedTemplates[0]?.id || '',

View File

@@ -220,12 +220,25 @@ export default function TemplateManage() {
pushHistory(); pushHistory();
if (placeholder.classList.contains('has-image')) { if (placeholder.classList.contains('has-image')) {
placeholder.classList.remove('has-image'); placeholder.classList.remove('has-image');
const w = parseInt(placeholder.style.maxWidth || placeholder.style.width || '0');
const text = w > 0 && w < 80 ? '插图' : '插入/点击放置图片';
placeholder.innerHTML = ` placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span> <span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span> <span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span>
`; `;
placeholder.style.border = '1px dashed #cbd5e1'; placeholder.style.border = '1px dashed #cbd5e1';
placeholder.style.background = '#f8fafc'; placeholder.style.background = '#f8fafc';
const mw = placeholder.style.maxWidth;
const mh = placeholder.style.maxHeight;
if (mw) placeholder.style.width = mw;
if (mh) {
placeholder.style.height = mh;
placeholder.style.lineHeight = mh;
}
placeholder.style.textAlign = 'center';
placeholder.style.verticalAlign = 'middle';
placeholder.style.justifyContent = 'center';
placeholder.style.alignItems = 'center';
} else { } else {
const range = document.createRange(); const range = document.createRange();
range.selectNode(placeholder); range.selectNode(placeholder);
@@ -682,7 +695,8 @@ export default function TemplateManage() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `模板导出-${template.name}.json`; const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
a.download = `模板导出-${template.name}-${ts}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };

View File

@@ -0,0 +1,34 @@
# 实现方案 —— 2026-04-19-00-33-44
## 方案目标
解决 30 天趋势图表过密问题,通过稀疏化标签 + Tooltip 提升可读性。
## 修改点
### 修改文件:`src/pages/Dashboard.tsx`
1. **条件渲染数据点和数值**:在 SVG 的 `points.map()` 中,对圆点和数值文本增加条件判断:
- 7 天模式:正常显示圆点和数值
- 30 天模式:不显示圆点和数值文本(仅保留折线和面积图)
2. **稀疏化 X 轴标签**
- 7 天模式:每天显示标签
- 30 天模式:每隔 5 天显示一次标签(`i % 5 === 0`
3. **SVG 鼠标事件与 Tooltip**
- 在图表容器上绑定 `onMouseMove``onMouseLeave`
- 计算鼠标在 SVG 坐标系中的相对位置,映射到最近的数据点索引
- 用 React state 管理 `tooltipData: { index, x, y, visible }`
- 用绝对定位的 div 渲染 Tooltip显示日期和数值
4. **透明捕获层**:在 SVG 中增加一个覆盖整个图表区域的透明 `<rect>`,确保鼠标在空白区域也能触发事件。
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/Dashboard.tsx` | 条件渲染 circle/text、标签稀疏化、Tooltip state、SVG 鼠标事件、透明捕获层 |
## 风险与注意事项
1. Tooltip 坐标计算需考虑 SVG 的 viewBox 到屏幕像素的映射比例。
2. 鼠标移出 SVG 区域时必须隐藏 Tooltip。
3. 7 天模式的显示效果必须保持完全不变。

View File

@@ -0,0 +1,44 @@
# 实现方案 —— 2026-04-19-00-51-07
## 方案目标
修改默认抽帧位置、模板导出文件名加时间戳、修复 TemplateManage 占位符删除恢复样式。
## 需求 1默认抽帧位置改为 [1,3,5,7,9,11]
### 修改文件:`src/pages/Login.tsx`
`initData()``defaultSettings` 中,将 `framePositions` 从当前值改为 `[1, 3, 5, 7, 9, 11]`
如果存在 `SystemSettings` 类型的默认值定义(如在其他文件中),也需要同步修改。
## 需求 2模板导出 JSON 文件名加时间戳
### 修改文件:`src/pages/TemplateManage.tsx`
`handleExportTemplate` 函数中,生成 `a.download` 时,在模板名称后追加北京时间戳(格式与批量导出一致)。
```ts
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
a.download = `模板导出-${template.name}-${ts}.json`;
```
## 需求 3TemplateManage 占位符删除恢复样式
### 修改文件:`src/pages/TemplateManage.tsx`
对比 ReportEditor.tsx 和 TemplateManage.tsx 中 `.image-placeholder` 删除后的恢复逻辑。
ReportEditor 中的恢复逻辑包含:`text-align:center``vertical-align:middle``justify-content:center``align-items:center`,使文字居中。
TemplateManage 中的恢复逻辑可能缺少这些居中样式,导致文字靠左。需要在 TemplateManage 的 `handleEditorClick` 删除恢复分支中补齐这些样式。
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/Login.tsx` | `defaultSettings.framePositions` 改为 `[1,3,5,7,9,11]` |
| `src/pages/TemplateManage.tsx` | `handleExportTemplate` 文件名加时间戳;`handleEditorClick` 删除恢复补齐居中样式 |
## 风险与注意事项
1. 修改 `defaultSettings` 只影响新用户或重置后的系统,已有用户的 localStorage 中的旧设置不会被覆盖。
2. 导出文件名中模板名称可能包含特殊字符,但时间戳追加在末尾不影响。
3. 居中样式需要确认 TemplateManage 中 placeholder 的 `display` 属性是否为 `inline-flex``inline-block`,以选择合适的居中方式。

View File

@@ -0,0 +1,29 @@
# 测试方案 —— 2026-04-19-00-33-44
## 测试目标
验证 30 天趋势图表稀疏化显示和 Tooltip 交互的正确性。
## 测试用例
### TC-130 天模式不显示圆点和数值
**步骤**
1. 进入 Dashboard点击"最近 30 天"。
**预期结果**:图表中仅显示面积图和折线,无蓝色圆点和数字 0/1/2... 等数值文本。
### TC-230 天模式标签稀疏化
**步骤**
1. 查看 30 天模式 X 轴。
**预期结果**:仅显示约 6 个日期标签(每隔 5 天一个),标签不重叠。
### TC-3Tooltip 悬停显示
**步骤**
1. 在 30 天模式图表上移动鼠标。
**预期结果**:鼠标旁出现 Tooltip显示当前位置的日期和报告数量移出图表区域后 Tooltip 消失。
### TC-47 天模式不受影响
**步骤**
1. 点击"最近 7 天"。
**预期结果**:每天显示圆点、数值和标签,无 Tooltip 行为(或 Tooltip 可选),与修改前完全一致。
## 测试通过标准
30 天模式图表清爽可读Tooltip 交互流畅7 天模式无变化。

View File

@@ -0,0 +1,32 @@
# 测试方案 —— 2026-04-19-00-51-07
## 测试目标
验证默认抽帧位置、导出文件名、占位符删除恢复的正确性。
## 测试用例
### TC-1默认抽帧位置正确
**步骤**
1. 清空 localStorage 或重置系统,重新登录。
2. 进入系统设置查看"抽帧位置百分比"。
**预期结果**:默认值为 1、3、5、7、9、11。
### TC-2模板导出文件名带时间戳
**步骤**
1. 进入 TemplateManage点击任意模板的"导出"按钮。
**预期结果**:下载的文件名为 `模板导出-模板名称-YYYY-MM-DD-HH-mm.json` 格式。
### TC-3TemplateManage 占位符删除后文字居中
**步骤**
1. 进入 TemplateManage插入图片占位符。
2. 点击占位符上传图片。
3. 点击删除按钮(×)删除图片。
**预期结果**:占位符恢复为虚线框,提示文字"插入/点击放置图片"在框内居中显示。
### TC-4ReportEditor 占位符不受影响
**步骤**
1. 进入 ReportEditor重复 TC-3 的操作。
**预期结果**:文字仍然居中,无变化。
## 测试通过标准
所有用例通过,无控制台报错,两端编辑器行为一致。

View File

@@ -1281,3 +1281,63 @@ if ((settings.autoInsertDelay || 0) > 0) {
- 在使用 SVG 绘制图表时,务必为 X 轴标签预留足够的底部空间(至少文字高度 + 安全间距),不能仅依赖 `overflow-visible`。 - 在使用 SVG 绘制图表时,务必为 X 轴标签预留足够的底部空间(至少文字高度 + 安全间距),不能仅依赖 `overflow-visible`。
- 当图表需要支持多时间维度时应在数据计算层useEffect统一处理而非在渲染层做条件分支确保数据与标签同步。 - 当图表需要支持多时间维度时应在数据计算层useEffect统一处理而非在渲染层做条件分支确保数据与标签同步。
- 增加 grid 列数时,需同步检查响应式断点(`md:`、`lg:`),避免在小屏幕上卡片过度挤压。 - 增加 grid 列数时,需同步检查响应式断点(`md:`、`lg:`),避免在小屏幕上卡片过度挤压。
---
## 记录 41Dashboard 30 天趋势图表稀疏化与 Tooltip 交互
**A. 具体问题**
Dashboard 中"最近 30 天"模式的趋势图表过于密集30 个蓝色圆点、30 个数值文本、30 个日期标签全部挤在底部,完全无法阅读。
**B. 产生问题原因**
1. SVG 图表对 7 天和 30 天采用完全相同的渲染策略,每天都绘制圆点、数值和标签。
2. 30 天模式下数据点密度是 7 天的 4 倍以上,在固定宽度的 SVG 中必然导致严重重叠。
3. 缺少悬停交互机制,用户无法在不显示所有数值的情况下查看具体某天的数据。
**C. 解决问题方案**
1. **条件渲染圆点和数值**:在 `points.map()` 中增加判断:`stats.trend.length <= 10` 时显示圆点和数值否则隐藏。7 天模式7 个点正常显示30 天模式30 个点)只保留折线和面积图。
2. **稀疏化 X 轴标签**`stats.trend.length <= 10 || i % 5 === 0`30 天模式每隔 5 天显示一个标签,从 30 个减少到约 6 个。
3. **SVG 鼠标事件与 Tooltip**
- 在 `<svg>` 上绑定 `onMouseMove` 和 `onMouseLeave`
- 通过 `getBoundingClientRect()` 将鼠标屏幕坐标映射到 SVG viewBox 坐标
- 计算最近的数据点索引:`idx = Math.round(((mouseX - paddingX) / chartW) * (n - 1))`
- 用 React state 管理 tooltip 的 `visible/x/y/date/count`
- 用绝对定位的 `div` 渲染 Tooltip显示完整日期和报告数
4. **透明捕获层**:在 SVG 中增加覆盖全区域的 `<rect fill="transparent" />`,确保鼠标在空白区域也能触发事件。
5. **完整日期存储**`stats` 中新增 `trendFullDates` 数组,存储 `YYYY-MM-DD` 格式完整日期,供 Tooltip 显示使用。
**D. 后续如何避免问题**
- 当图表需要支持多时间维度时,必须考虑不同密度下的渲染策略差异,不能对所有维度一视同仁。
- SVG 的鼠标事件坐标映射需要注意 `viewBox` 与实际显示尺寸的缩放比例,通过 `getBoundingClientRect()` 做比例换算是可靠方案。
- Tooltip 等浮动层应使用 `pointer-events-none` 避免干扰下层交互,同时确保在容器 `relative` 定位下正确计算偏移。
- 透明捕获层是解决 SVG 内部元素间隙导致事件丢失的有效手段,特别是在只有线条/路径的图表中。
---
## 记录 42默认抽帧位置、导出文件名、占位符删除恢复样式修复
**A. 具体问题**
1. 系统初始化时默认抽帧位置为 12 个均匀分布点(约 7.7%~92.3%),用户希望改为前 6 个奇数百分比点1%、3%、5%、7%、9%、11%)。
2. TemplateManage 单模板导出 JSON 文件名只有模板名称,缺少时间戳,不便于版本管理。
3. TemplateManage 编辑器中 `.image-placeholder` 删除图片后,提示文字"插入/点击放置图片"偏向左侧,而 ReportEditor 中同样的操作文字是居中的。
**B. 产生问题原因**
1. `Login.tsx` 的 `initData()` 中使用 `for (let i = 1; i <= 12; i++)` 循环生成 12 个均匀百分比,不符合临床场景下"前段密集抽帧"的习惯。
2. `handleExportTemplate` 中直接拼接 `模板导出-${template.name}.json`,未追加时间戳。
3. TemplateManage 的删除恢复逻辑仅设置了 `border` 和 `background`,缺少 `textAlign`、`verticalAlign`、`justifyContent`、`alignItems` 等居中样式,且未恢复 `width`/`height`/`lineHeight`,也未使用 `absolute` 定位的居中方案。
**C. 解决问题方案**
1. **默认抽帧位置**:将 `frameCount` 从 12 改为 6`framePositions` 从均匀计算改为硬编码 `[1, 3, 5, 7, 9, 11]`。
2. **导出文件名**:在 `a.download` 前加入北京时间戳生成逻辑,格式为 `模板导出-模板名称-YYYY-MM-DD-HH-mm.json`。
3. **删除恢复样式**:在 TemplateManage 的删除恢复逻辑中补齐:
- `placeholder-text` 使用 `position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;` 实现绝对居中
- 恢复 `width`(从 `maxWidth`)、`height`/`lineHeight`(从 `maxHeight`
- 设置 `textAlign='center'`、`verticalAlign='middle'`、`justifyContent='center'`、`alignItems='center'`
- 根据占位符原始宽度判断显示"插图"<80px或"插入/点击放置图片"
**D. 后续如何避免问题**
- 当两个编辑器ReportEditor / TemplateManage共享同一种控件如 `image-placeholder`)时,任何对创建/填充/删除恢复逻辑的修改都必须在两端同步检查,避免用户体验不一致。
- 系统默认值(如抽帧位置)的修改应考虑已有用户的数据兼容问题:新默认值只影响 localStorage 中无记录的新用户或重置后的系统。
- 导出文件名加时间戳时,应统一使用项目内已有的北京时间生成函数或模式,避免不同模块使用不同格式。

View File

@@ -0,0 +1,22 @@
# 需求分析 —— 2026-04-19-00-33-44
## 需求来源
用户发现 Dashboard 中"最近 30 天"模式下的趋势图表过于密集30 个数据圆点、30 个数值文本、30 个日期标签挤在一起,完全无法阅读。
## 需求概述
### 需求 130 天模式稀疏化显示
在"最近 30 天"模式下:
- 不绘制每一天的数据圆点 `<circle>` 和数值文本 `<text>`
- 仅保留平滑的面积图轮廓和折线
- X 轴日期标签每隔 5 天显示一次(稀疏化)
### 需求 2Tooltip 悬停交互
- 平时隐藏具体数值
- 鼠标悬停在曲线区域时,显示浮动 Tooltip展示该位置对应的日期和报告数量
## 涉及文件
- `src/pages/Dashboard.tsx`
## 需求影响范围
仅影响 Dashboard 趋势图表的 SVG 渲染和交互逻辑7 天模式保持不变。

View File

@@ -0,0 +1,24 @@
# 需求分析 —— 2026-04-19-00-51-07
## 需求来源
用户在使用中发现三个问题默认抽帧位置不符合临床习惯、模板导出文件名缺少时间戳、TemplateManage 图片占位符删除后文字靠左。
## 需求概述
### 需求 1默认抽帧位置改为第 1、3、5、7、9、11 帧
系统初始化或重置数据时,自动关键帧摘取的默认位置百分比应从当前值改为 `[1, 3, 5, 7, 9, 11]`,对应视频的第 1%、3%、5%、7%、9%、11% 位置。
### 需求 2模板导出 JSON 文件名加时间戳
TemplateManage 中点击单个模板的"导出"按钮时,导出的 JSON 文件名应从 `模板导出-模板名称.json` 改为 `模板导出-模板名称-时间戳.json`
### 需求 3TemplateManage 图片占位符删除后文字靠左
TemplateManage 编辑器中,`.image-placeholder` 插入图片后再删除恢复,提示文字"插入/点击放置图片"偏向左侧;而 ReportEditor 中同样的操作文字是居中的。需要统一两者的删除恢复逻辑。
## 涉及文件
- `src/pages/Login.tsx`(或系统设置初始化位置)— 需求 1
- `src/pages/TemplateManage.tsx` — 需求 2、3
## 需求影响范围
- 系统默认配置
- 模板导出文件名格式
- TemplateManage 编辑器占位符删除恢复样式