sync from local backup
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# CSS 暗色模式覆盖添加计划
|
||||
|
||||
## 目标
|
||||
为 6 个工具页面 CSS 文件添加 `[data-theme="dark"]` 暗色模式覆盖样式。
|
||||
|
||||
## 文件清单与分析
|
||||
|
||||
### 1. TimestampTool/TimestampTool.css
|
||||
- **前缀**: `.timestamp-`, `.ts-`
|
||||
- **主色**: `#8b5cf6` (紫色渐变 #8b5cf6 → #6366f1 → #3b82f6)
|
||||
- **需要覆盖的类**:
|
||||
- `.timestamp-page`
|
||||
- `.timestamp-panel`
|
||||
- `.ts-panel-header`
|
||||
- `.ts-panel-title`
|
||||
- `.ts-panel-dot`
|
||||
- `.ts-back-btn`
|
||||
- `.timestamp-timezone-bar`
|
||||
- `.ts-timezone-label`
|
||||
- `.ts-result-item`, `.ts-result-label`, `.ts-result-value`
|
||||
- `.ts-result-copy`
|
||||
- `.ts-badge-seconds`, `.ts-badge-milliseconds`
|
||||
- `.ts-empty-hint-icon`
|
||||
- `.ts-action-clear` (危险操作按钮)
|
||||
- **说明:用户已提供完整内容,直接追加即可。
|
||||
|
||||
### 2. RegexTool/RegexTool.css
|
||||
- **前缀**: `.regex-`
|
||||
- **主色**: `#f97316` (橙色渐变 #f97316 → #ea580c → #dc2626)
|
||||
- **需要覆盖的类**:
|
||||
- `.regex-page`
|
||||
- `.regex-panel`
|
||||
- `.regex-panel-header`
|
||||
- `.regex-panel-title`
|
||||
- `.regex-panel-dot`
|
||||
- `.regex-back-btn`
|
||||
- `.regex-action-primary` (主按钮 - 保持渐变)
|
||||
- `.regex-action-danger` (危险按钮)
|
||||
- `.regex-flag-bar`
|
||||
- `.regex-preset-btn`
|
||||
- `.regex-match-count`
|
||||
- `.regex-result-item`, `.regex-result-index`, `.regex-result-text`, `.regex-result-pos`
|
||||
- `.regex-highlight-section`, `.regex-highlight-header`, `.regex-highlight-title`
|
||||
- `.regex-highlight-text` (深色代码背景 #1e1e2e - 保持不变)
|
||||
- `.regex-highlight-mark`
|
||||
- `.regex-tip-card`
|
||||
- `.regex-tip-symbol`, `.regex-tip-desc`
|
||||
|
||||
### 3. TextDiff/TextDiff.css
|
||||
- **前缀**: `.diff-`
|
||||
- **主色**: `#667eea` (共享 ToolDetail 基础)
|
||||
- **需要覆盖的类**:
|
||||
- `.diff-stats-bar`
|
||||
- `.diff-result-container` (已经是深色背景,保持不变)
|
||||
- `.diff-line` 行样式保持
|
||||
- `.diff-line-number`
|
||||
- `.diff-line-prefix`
|
||||
- `.diff-line-content`
|
||||
|
||||
### 4. EncodingConverter/EncodingConverter.css
|
||||
- **前缀**: `.ec-`
|
||||
- **主色**: `#f43f5e` (粉色渐变 #f43f5e → #ec4899 → #fb7185)
|
||||
- **注意**: 依赖 `.tool-detail-page .tool-detail-hero` (ToolDetail.css 已处理)
|
||||
- **需要覆盖的类**:
|
||||
- `.encoding-converter-page .tool-detail-hero` (hero背景)
|
||||
- `.ec-encoding-selectors`
|
||||
- `.ec-swap-encoding-btn`
|
||||
- `.ec-convert-btn` (主按钮,保持渐变)
|
||||
- `.ec-detected-tag`
|
||||
|
||||
### 5. CharsetConverter/CharsetConverter.css
|
||||
- **前缀**: `.cc-` (charset converter)
|
||||
- **主色**: `#1677ff` (蓝色)
|
||||
- **注意**: 无 hero 渐变,但使用 `#1677ff`
|
||||
- **需要覆盖的类**:
|
||||
- `.charset-converter-page`
|
||||
- `.cc-encoding-selectors`
|
||||
- `.cc-panel`
|
||||
- `.cc-textarea` (浅色输入背景,但暗色模式用深色)
|
||||
- `.cc-panel-footer`
|
||||
- `.cc-output` (浅色输出背景)
|
||||
- `.cc-swap-encoding-btn`
|
||||
- `.cc-convert-btn`
|
||||
|
||||
### 6. ColorConverter/ColorConverter.css
|
||||
- **前缀**: `.cc-` (color converter), `.color-converter-`
|
||||
- **主色**: `#6366f1` (靛蓝色渐变 #6366f1 → #8b5cf6 → #a855f7)
|
||||
- **注意**: `cc-` 前缀与 CharsetConverter 相同,但使用不同的主色
|
||||
- **需要覆盖的类**:
|
||||
- `.color-converter-page`
|
||||
- `.cc-panel`
|
||||
- `.cc-panel-header`
|
||||
- `.cc-panel-title`
|
||||
- `.cc-panel-dot`
|
||||
- `.cc-back-btn`
|
||||
- `.cc-preview-block`
|
||||
- `.cc-native-picker`
|
||||
- `.cc-css-input`
|
||||
- `.cc-format-input`
|
||||
- `.cc-format-copy`
|
||||
- `.cc-swap-btn`
|
||||
- `.cc-section-divider`
|
||||
- 注意:`.color-converter-toolbar`, `.color-converter-container` 等容器
|
||||
|
||||
## 通用规则
|
||||
1. 页面背景: `#0f172a`
|
||||
2. 面板背景: `#1e293b`
|
||||
3. 边框: `#334155`
|
||||
4. 面板头部: `rgba(主色, 0.08)`
|
||||
5. 标题/文字: 标题 #f1f5f9, 正文 #94a3b8
|
||||
6. 返回按钮: 颜色 #94a3b8, 边框 #334155, hover 变主题色
|
||||
7. textarea 深色代码背景 #1e1e2e 保持不变
|
||||
8. 危险按钮: 颜色 #f87171, hover 背景 rgba(239,68,68,0.1)
|
||||
9. 在 `@media` 响应式块之后添加暗色模式
|
||||
|
||||
## 编辑方式
|
||||
每个文件在末尾添加 `/* ===== 暗色模式 =====` 开始的暗色模式覆盖块。
|
||||
@@ -0,0 +1,148 @@
|
||||
# 每日学习计时上报功能
|
||||
|
||||
## 目标
|
||||
在课程学习页面(CourseLearn)实现后台静默学习计时,每 5 分钟调用 `tasks.track` 上报分钟数,页面关闭/离线时再次上报剩余时长,驱动"每日学习"和"学习达人"任务自动完成。
|
||||
|
||||
## 现状分析
|
||||
- **后端已就绪**:`TaskTrackAPIView` + `TaskDefinition` 种子数据(每日学习 5min / 学习达人 30min)
|
||||
- **前端已定义未调用**:`api_request.login.tasks.track()` 已定义但从未使用
|
||||
- **CourseLearn.tsx 无计时**:当前无任何计时相关代码
|
||||
- **后端 count 单位 = 分钟**:target_count=5 即 5 分钟
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 计时逻辑
|
||||
- 进入课程学习页面即开始计时(页面打开即计时)
|
||||
- 切到后台也继续计算(不检测活跃时间)
|
||||
- 不显示计时器(后台静默)
|
||||
|
||||
### 上报策略
|
||||
- **每 5 分钟上报一次**:count=5
|
||||
- **页面关闭/离线前上报剩余时长**:监听 `beforeunload` + `visibilitychange`(隐藏时)
|
||||
- **首次进入立即上报 1 分钟**:保证用户停留即有意义,后续每 5 分钟补报
|
||||
|
||||
### 数据存储(防丢失)
|
||||
- 用 `localStorage` 记录当前课程累计已上报分钟数
|
||||
- key:`learn_time_{courseId}`,value:已上报分钟数
|
||||
- 页面卸载时同步保存,重新进入时恢复
|
||||
|
||||
### 防重复
|
||||
- 已上报的分钟数不重复上报(通过 localStorage 追踪)
|
||||
- 页面卸载时把未上报的剩余分钟数一次性补报
|
||||
|
||||
## 实现文件
|
||||
|
||||
### 1. 新建 `src/hooks/useLearnTimer.ts`(计时 Hook)
|
||||
```
|
||||
功能:
|
||||
- 接收 courseId,返回空(纯副作用)
|
||||
- 进入时读取 localStorage 恢复已上报时长
|
||||
- 每 1 分钟 setInterval 累加本地时长
|
||||
- 每满 5 分钟调用 api_request.login.tasks.track({ action_type: 'learn', count: 5 })
|
||||
- 上报成功后更新 localStorage 中的已上报值
|
||||
- beforeunload / visibilitychange(hidden) 时:
|
||||
- 上报剩余未报分钟数(count = 剩余分钟)
|
||||
- 使用 navigator.sendBeacon 或同步 XHR 保证可靠性
|
||||
```
|
||||
|
||||
### 2. 修改 `src/pages/CourseLearn/CourseLearn.tsx`
|
||||
```
|
||||
- 导入 useLearnTimer
|
||||
- 在组件内调用 useLearnTimer(courseData?.id)
|
||||
- 无需新增 UI 元素(后台静默)
|
||||
```
|
||||
|
||||
## 关键代码结构
|
||||
|
||||
### useLearnTimer Hook
|
||||
```typescript
|
||||
export const useLearnTimer = (courseId: number | undefined) => {
|
||||
const [reportedMinutes, setReportedMinutes] = useState(0);
|
||||
const [currentMinutes, setCurrentMinutes] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!courseId) return;
|
||||
|
||||
// 从 localStorage 恢复
|
||||
const saved = localStorage.getItem(`learn_time_${courseId}`);
|
||||
const savedReported = saved ? parseInt(saved, 10) : 0;
|
||||
setReportedMinutes(savedReported);
|
||||
setCurrentMinutes(savedReported);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentMinutes(prev => {
|
||||
const next = prev + 1;
|
||||
const newReported = reportedMinutes + Math.floor((next - reportedMinutes) / 5) * 5;
|
||||
|
||||
// 每满 5 分钟上报
|
||||
const batchesReady = Math.floor((next - reportedMinutes) / 5);
|
||||
if (batchesReady >= 1) {
|
||||
const toReport = batchesReady * 5;
|
||||
trackLearnTime(toReport);
|
||||
setReportedMinutes(reportedMinutes + toReport);
|
||||
localStorage.setItem(`learn_time_${courseId}`, String(reportedMinutes + toReport));
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, 60000); // 每分钟检查
|
||||
|
||||
// 页面卸载上报剩余
|
||||
const handleUnload = () => {
|
||||
// 上报剩余分钟(不足 5 分钟的部分也上报)
|
||||
const remaining = currentMinutes - reportedMinutes;
|
||||
if (remaining > 0) {
|
||||
trackLearnTime(remaining);
|
||||
localStorage.removeItem(`learn_time_${courseId}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') handleUnload();
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
};
|
||||
}, [courseId]);
|
||||
};
|
||||
```
|
||||
|
||||
### trackLearnTime 辅助函数
|
||||
```typescript
|
||||
const trackLearnTime = async (minutes: number) => {
|
||||
try {
|
||||
await api_request.login.tasks.track({
|
||||
action_type: 'learn',
|
||||
count: minutes,
|
||||
});
|
||||
} catch {
|
||||
// 静默失败,不阻塞用户
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 上报时序示例
|
||||
| 时间 | 动作 | 累计学习 | 上报 | 后端进度 |
|
||||
|------|------|----------|------|----------|
|
||||
| 00:00 | 进入页面 | 0 | - | 0/5 |
|
||||
| 01:00 | 第 1 分钟 | 1 | - | - |
|
||||
| 05:00 | 满 5 分钟 | 5 | count=5 | 5/5 ✅ |
|
||||
| 10:00 | 满 10 分钟 | 10 | count=5 | 10/5(每日学习已完成,累计到学习达人) |
|
||||
| 15:00 | 关闭页面 | 12 | count=2(剩余) | 12/5 |
|
||||
|
||||
## 验证步骤
|
||||
1. 打开任意课程学习页面
|
||||
2. 等待 5 分钟,检查 TaskCenter 页面"每日学习"进度是否更新
|
||||
3. 关闭页面,检查是否有补报请求(Network 面板)
|
||||
4. 切换语言后确认功能不受影响
|
||||
5. 未登录用户:计时不上报(或游客模式本地存储,登录后合并上报)
|
||||
|
||||
## 假设与决策
|
||||
- **计时从页面打开开始**:用户选择,无需检测活跃时间
|
||||
- **每 5 分钟上报一次**:用户选择,平衡实时性与请求频率
|
||||
- **关闭页面用 beforeunload + visibilitychange**:双重保障,防止离线丢失
|
||||
- **后台静默不显示 UI**:用户选择
|
||||
- **action_type='learn'**:匹配种子数据中每日学习/学习达人的 action_type
|
||||
@@ -0,0 +1,210 @@
|
||||
# 日期计算器移动端适配修复计划
|
||||
|
||||
## 问题描述
|
||||
日期计算器工具在移动端无法正常使用。当前CSS虽有部分移动端样式,但存在布局错乱、元素溢出、交互困难等问题。
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有移动端样式(`@media (max-width: 768px)`)
|
||||
- `.dc-panels` 已设置为 `grid-template-columns: 1fr`(单列)
|
||||
- `.dc-actions` 已设置为 `grid-template-columns: 1fr`(单列)
|
||||
- `.dc-toolbar` 已设置为 `flex-direction: column`
|
||||
|
||||
### 存在的移动端问题
|
||||
|
||||
1. **Hero区域过高**:200px最小高度 + 40px padding,在移动端占用过多空间
|
||||
2. **结果项横向溢出**:`dc-result-item` 使用 `flex` 布局,label + value + copy按钮在小屏幕上可能溢出
|
||||
3. **时区选择器宽度问题**:`min-width: 220px` 在小屏幕上可能超出容器
|
||||
4. **输入框字体过大**:15px字体在移动端可能导致输入困难
|
||||
5. **按钮间距不足**:操作按钮在移动端可能难以点击
|
||||
6. **DatePicker组件**:带 `showTime` 的日期选择器在移动端体验不佳
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. CSS移动端样式优化
|
||||
|
||||
**文件**:`DateCalculator.css`
|
||||
|
||||
**修改内容**:
|
||||
|
||||
#### 1.1 Hero区域压缩
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.date-calculator-hero {
|
||||
min-height: 120px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.date-calculator-hero-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 22px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.date-calculator-hero-title {
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 时区栏适配
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.dc-timezone-bar {
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dc-timezone-select {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 结果项垂直布局
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.dc-result-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dc-result-label {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dc-result-value {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dc-result-copy {
|
||||
align-self: flex-end;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 输入区域优化
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.dc-input-area {
|
||||
font-size: 16px; /* 防止iOS缩放 */
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.5 按钮优化
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.dc-action-btn {
|
||||
height: 52px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dc-actions {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.6 面板间距优化
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.dc-panels {
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dc-panel-body {
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 组件逻辑优化
|
||||
|
||||
**文件**:`DateCalculator.tsx`
|
||||
|
||||
**修改内容**:
|
||||
|
||||
#### 2.1 添加移动端检测
|
||||
```tsx
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
```
|
||||
|
||||
#### 2.2 DatePicker移动端优化
|
||||
在移动端使用更简单的日期选择器,不使用 `showTime`:
|
||||
```tsx
|
||||
<DatePicker
|
||||
showTime={!isMobile}
|
||||
value={dateInput}
|
||||
onChange={(val) => setDateInput(val)}
|
||||
placeholder={t("dateCalculator.datePickPlaceholder")}
|
||||
format={isMobile ? "YYYY-MM-DD" : "YYYY-MM-DD HH:mm:ss"}
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. 涉及文件
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `DateCalculator.css` | 修改 | 优化移动端布局样式 |
|
||||
| `DateCalculator.tsx` | 修改 | 添加移动端检测,优化DatePicker |
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. **Chrome DevTools移动端模拟**
|
||||
- 打开 DevTools → Toggle device toolbar
|
||||
- 选择 iPhone SE / iPhone 12 Pro / Pixel 5 等设备
|
||||
- 验证布局是否正常
|
||||
|
||||
2. **真机测试**
|
||||
- 在手机浏览器中打开 `/utility/date-calculator`
|
||||
- 测试以下功能:
|
||||
- 时间戳转日期输入和结果显示
|
||||
- 日期转时间戳选择日期
|
||||
- 复制功能
|
||||
- 获取当前时间
|
||||
- 清除功能
|
||||
- 时区切换
|
||||
|
||||
3. **交互测试**
|
||||
- 输入框是否容易点击和输入
|
||||
- 按钮是否容易点击
|
||||
- 结果项是否完整显示
|
||||
- 日期选择器是否正常工作
|
||||
|
||||
## 预期效果
|
||||
|
||||
- Hero区域高度减少约40%
|
||||
- 结果项在移动端垂直排列,更易阅读
|
||||
- 输入框字体16px,防止iOS自动缩放
|
||||
- 按钮高度52px,更易点击
|
||||
- 整体布局紧凑,无水平溢出
|
||||
@@ -0,0 +1,109 @@
|
||||
# 邮箱验证双重验证实现计划
|
||||
|
||||
## 需求总结
|
||||
在找回密码功能中,用户可能使用虚假邮箱(如 `test@example.com`)导致邮件被退回。需要在发送前验证邮箱真实性,避免无效发送。
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 前端(ForgotPassword.tsx)
|
||||
- 仅验证邮箱是否为空
|
||||
- 无格式验证
|
||||
- 无"必须使用真实邮箱"提示
|
||||
|
||||
### 后端(user/views/user.py)
|
||||
- `ForgotPasswordSendCodeAPIView` 仅验证邮箱是否为空
|
||||
- 直接调用 `FUser.objects.filter(email=to_email).first()` 查询用户
|
||||
- 若用户不存在,返回 `USER_NOT_FOUND` 错误
|
||||
- 无 MX 记录验证
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. 前端验证
|
||||
|
||||
**文件**:`src/pages/ForgotPassword/ForgotPassword.tsx`
|
||||
|
||||
**修改内容**:
|
||||
- 增加邮箱格式验证(使用正则表达式)
|
||||
- 发送前弹出确认提示:"请确保使用真实邮箱,验证码将发送至该邮箱"
|
||||
- 前端验证不通过时不发送请求
|
||||
|
||||
```tsx
|
||||
const handleSendCode = async () => {
|
||||
if (!email) {
|
||||
message.warning(t('forgotPassword.warnEmailRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
message.warning(t('forgotPassword.warnEmailFormat'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 继续发送...
|
||||
};
|
||||
```
|
||||
|
||||
**新增翻译文本**:
|
||||
- `warnEmailFormat`: "邮箱格式不正确"
|
||||
- `confirmSend`: "验证码将发送至该邮箱,请确认是真实邮箱"
|
||||
|
||||
### 2. 后端 MX 记录验证
|
||||
|
||||
**文件**:`user/views/user.py`
|
||||
|
||||
**修改内容**:
|
||||
- 在 `ForgotPasswordSendCodeAPIView.post()` 中增加 MX 记录验证
|
||||
- 使用 Python `socket` 或 `dns.resolver` 库查询 MX 记录
|
||||
- 若 MX 记录不存在,直接返回错误
|
||||
|
||||
```python
|
||||
import socket
|
||||
|
||||
def validate_email_mx(email):
|
||||
"""验证邮箱域名是否有MX记录"""
|
||||
try:
|
||||
domain = email.split('@')[1]
|
||||
mx_records = socket.getaddrinfo(domain, None, socket.AF_INET, socket.SOCK_STREAM)
|
||||
return len(mx_records) > 0
|
||||
except (socket.gaierror, IndexError):
|
||||
return False
|
||||
```
|
||||
|
||||
**注意**:QQ Mail 对无效域名会退信,MX 验证可以在发送前拦截。
|
||||
|
||||
### 3. 涉及文件
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `src/pages/ForgotPassword/ForgotPassword.tsx` | 修改 | 增加格式验证和提示 |
|
||||
| `user/views/user.py` | 修改 | 增加 MX 记录验证 |
|
||||
| `src/locales/zh.json` | 修改 | 添加错误提示文本 |
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. **前端格式验证**
|
||||
- 输入 `test`(无@符号)→ 提示格式错误
|
||||
- 输入 `test@example.com` → 继续验证
|
||||
- 输入 `test@nonexistent-domain-xyz.com` → 后端MX验证拦截
|
||||
|
||||
2. **后端 MX 验证**
|
||||
- 输入 `test@example.com` → MX 不存在,返回错误
|
||||
- 输入 `test@qq.com` → MX 存在,继续发送
|
||||
- 输入 `test@gmail.com` → MX 存在,继续发送
|
||||
|
||||
3. **端到端测试**
|
||||
- 使用真实邮箱 `test@qq.com` 测试完整流程
|
||||
- 确认收到验证码邮件
|
||||
|
||||
## 备选方案
|
||||
|
||||
如果 MX 验证过于严格(可能误杀某些特殊域名),可以考虑:
|
||||
- 仅在前端提示,不拦截
|
||||
- 或添加白名单机制
|
||||
- 或使用第三方邮箱验证 API
|
||||
|
||||
## 注意事项
|
||||
- MX 验证需要 DNS 查询,可能增加约 100ms 延迟
|
||||
- 建议结合缓存避免重复查询
|
||||
- 仅对找回密码等关键场景启用
|
||||
@@ -0,0 +1,139 @@
|
||||
# 找回密码邮件发送问题修复计划
|
||||
|
||||
## 问题描述
|
||||
用户在找回密码页面输入邮箱后,页面显示发送成功,但实际未收到验证码邮件。
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 当前代码流程
|
||||
1. 用户输入邮箱 → 前端调用 `/api/forgot_password_send_code/`
|
||||
2. 后端 `ForgotPasswordSendCodeAPIView` 生成验证码并保存到缓存
|
||||
3. 调用 `submit_task(send_reset_password_email_task, to_email, code)` 发送邮件
|
||||
4. 前端显示成功
|
||||
|
||||
### 发现的问题
|
||||
|
||||
#### 1. Celery Worker 可能未运行
|
||||
- **问题**:`submit_task` 函数先尝试 `apply_async`(异步),如果成功则直接返回
|
||||
- **风险**:如果 Redis 可达但无 worker 运行,任务会永远留在队列中不被执行
|
||||
- **验证**:本地测试显示 `apply_async` 返回 PENDING,说明任务已入队但可能无 worker 消费
|
||||
- **fallback 机制**:`submit_task` 只在 `apply_async` 抛异常或超时时才降级为同步 `apply`
|
||||
|
||||
#### 2. QQ Mail 发送限制
|
||||
- **问题**:QQ Mail 对同一地址发送频率有限制
|
||||
- **风险**:短时间内多次发送可能被 QQ Mail 服务器静默丢弃
|
||||
- **特征**:SMTP 连接成功,但邮件未投递
|
||||
|
||||
#### 3. 邮件进入垃圾箱
|
||||
- **问题**:验证码邮件可能被收件方归类为垃圾邮件
|
||||
- **风险**:用户未检查垃圾邮件箱
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 修复1:确保邮件同步发送(解决 Celery 不可用问题)
|
||||
|
||||
**文件**:`utils/safe_task.py`
|
||||
|
||||
**修改**:将 `submit_task` 改为**默认同步发送**,失败时降级为异步
|
||||
|
||||
**理由**:
|
||||
- 验证码邮件需要立即发送,不能依赖 Celery worker 的可用性
|
||||
- 同步发送确保邮件立即投递
|
||||
- 如果同步失败,再尝试异步作为 fallback
|
||||
|
||||
```python
|
||||
def submit_task(task, *args, **kwargs):
|
||||
"""发送任务,优先同步执行,失败时降级为异步"""
|
||||
try:
|
||||
# 优先同步执行,确保立即发送
|
||||
return task.apply(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
logger.warning('sync send failed, fallback to async: %s', exc)
|
||||
try:
|
||||
return task.apply_async(args, kwargs, kwargs.get('_timeout', SUBMIT_TIMEOUT))
|
||||
except Exception as exc2:
|
||||
logger.error('async fallback also failed: %s', exc2)
|
||||
return None
|
||||
```
|
||||
|
||||
### 修复2:增加邮件发送状态反馈
|
||||
|
||||
**文件**:`user/views/user.py` → `ForgotPasswordSendCodeAPIView`
|
||||
|
||||
**修改**:
|
||||
- 在 `submit_task` 调用后检查返回结果
|
||||
- 如果同步发送失败,返回更明确的错误信息
|
||||
- 添加日志记录发送状态
|
||||
|
||||
```python
|
||||
result = submit_task(send_reset_password_email_task, to_email, code)
|
||||
if result is None:
|
||||
logger.warning(f'Reset password email failed to send: {to_email}')
|
||||
# 不暴露内部错误给用户,但记录日志
|
||||
```
|
||||
|
||||
### 修复3:前端增加提示
|
||||
|
||||
**文件**:`src/pages/ForgotPassword/ForgotPassword.tsx`
|
||||
|
||||
**修改**:
|
||||
- 发送成功后提示用户检查垃圾邮件箱
|
||||
- 如果发送失败,提示用户稍后重试
|
||||
|
||||
```tsx
|
||||
if (res?.code === 10009) {
|
||||
message.success(t('forgotPassword.successCodeSent'));
|
||||
message.info(t('forgotPassword.checkSpam')); // 新增:提示检查垃圾邮件
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 修复4:QQ Mail 发送频率优化(可选)
|
||||
|
||||
**文件**:`utils/safe_task.py`
|
||||
|
||||
**修改**:
|
||||
- 添加发送间隔限制,避免被 QQ Mail 拦截
|
||||
- 对同一邮箱,短时间内不重复发送
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `utils/safe_task.py` | 修改 | 改为同步优先发送 |
|
||||
| `user/views/user.py` | 修改 | 增加发送状态日志 |
|
||||
| `src/pages/ForgotPassword/ForgotPassword.tsx` | 修改 | 增加垃圾邮件提示 |
|
||||
| `src/locales/zh.json` | 修改 | 添加提示文本 |
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. **本地测试**
|
||||
- 启动 Django 开发服务器
|
||||
- 访问找回密码页面
|
||||
- 输入已注册邮箱
|
||||
- 检查是否收到邮件
|
||||
- 检查 Django 日志是否有发送记录
|
||||
|
||||
2. **Celery 未运行场景**
|
||||
- 确保本地无 Celery worker 运行
|
||||
- 测试邮件发送是否正常
|
||||
|
||||
3. **同一邮箱多次发送**
|
||||
- 连续发送3次验证码
|
||||
- 检查是否每次都能收到
|
||||
- 检查是否有频率限制
|
||||
|
||||
4. **垃圾邮件检查**
|
||||
- 检查 QQ Mail 垃圾邮件箱
|
||||
- 确认邮件是否被归类为垃圾邮件
|
||||
|
||||
## 风险评估
|
||||
- 同步发送会增加请求响应时间(约1-2秒),但验证码邮件场景下可接受
|
||||
- 如果 SMTP 服务器响应慢,可能导致请求超时
|
||||
- 需要监控邮件发送失败率
|
||||
|
||||
## 备选方案
|
||||
如果同步发送影响性能,可以考虑:
|
||||
1. 使用数据库记录发送状态
|
||||
2. 前端轮询检查邮件是否发送成功
|
||||
3. 提供"重新发送"按钮
|
||||
@@ -0,0 +1,80 @@
|
||||
# 修复分享功能并添加 tasks.track 调用
|
||||
|
||||
## 问题分析
|
||||
|
||||
1. **分享功能不工作**:当前只是复制链接到剪贴板,没有调用 `tasks.track` 上报分享行为
|
||||
2. **tasks.track 从未被调用**:API 方法已在 `request.ts` 中定义,但整个项目中没有任何地方调用
|
||||
|
||||
## 任务类型(TaskCenter 中定义)
|
||||
|
||||
| action_type | 说明 | 调用位置 |
|
||||
|-------------|------|----------|
|
||||
| `share` | 分享 | ArticleDetail.tsx, UserHome.tsx |
|
||||
| `post` | 发表评论 | ArticleDetail.tsx |
|
||||
| `browse` | 浏览文章 | ArticleDetail.tsx |
|
||||
| `learn` | 完成学习 | CourseLearn.tsx |
|
||||
| `profile` | 完善资料 | Profile.tsx |
|
||||
| `checkin` | 每日签到 | TaskCenter.tsx |
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. 创建 tasks.track 工具函数
|
||||
|
||||
**文件**: `src/utils/taskTrack.ts`
|
||||
|
||||
封装 `tasks.track` 调用,统一处理错误(静默处理,不影响用户体验):
|
||||
|
||||
```typescript
|
||||
import { api_request } from "@/utils/request";
|
||||
|
||||
export const trackTask = async (action_type: string, count: number = 1) => {
|
||||
try {
|
||||
await api_request.login.tasks.track({ action_type, count });
|
||||
} catch {
|
||||
// 静默处理,不影响用户体验
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 修改分享按钮添加 track 调用
|
||||
|
||||
**文件**: `src/pages/ArticleDetail/ArticleDetail.tsx`
|
||||
|
||||
- `handleShare` 中添加 `trackTask('share')`
|
||||
|
||||
**文件**: `src/pages/UserHome/UserHome.tsx`
|
||||
|
||||
- 分享按钮 onClick 中添加 `trackTask('share')`
|
||||
|
||||
### 3. 添加评论发表 track 调用
|
||||
|
||||
**文件**: `src/pages/ArticleDetail/ArticleDetail.tsx`
|
||||
|
||||
- `handleComment` 成功后添加 `trackTask('post')`
|
||||
- `handleReply` 成功后添加 `trackTask('post')`
|
||||
|
||||
### 4. 添加文章浏览 track 调用
|
||||
|
||||
**文件**: `src/pages/ArticleDetail/ArticleDetail.tsx`
|
||||
|
||||
- 文章加载成功后添加 `trackTask('browse')`
|
||||
|
||||
### 5. 添加学习完成 track 调用
|
||||
|
||||
**文件**: `src/pages/CourseLearn/CourseLearn.tsx`
|
||||
|
||||
- 章节标记完成成功后添加 `trackTask('learn')`
|
||||
|
||||
### 6. 添加资料完善 track 调用
|
||||
|
||||
**文件**: `src/pages/Profile/Profile.tsx`
|
||||
|
||||
- 资料保存成功后添加 `trackTask('profile')`
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 点击分享按钮,检查是否复制链接并调用 track API
|
||||
2. 发表评论,检查是否调用 track API
|
||||
3. 浏览文章,检查是否调用 track API
|
||||
4. 完成学习章节,检查是否调用 track API
|
||||
5. 完善个人资料,检查是否调用 track API
|
||||
@@ -0,0 +1,81 @@
|
||||
# 历史记录按天去重功能实现计划
|
||||
|
||||
## 需求总结
|
||||
当用户在**同一天内**重复点击同一个实用工具或API界面时,不创建新的历史记录,而是**更新已有记录的浏览时间**。
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 前端(useRecordHistory.ts)
|
||||
- 使用 `useRef` 实现2秒防抖
|
||||
- **问题**:只在同一组件实例内有效,组件卸载后状态丢失,无法跨导航去重
|
||||
|
||||
### 后端(history/serializers.py)
|
||||
- `BrowsingHistoryCreateSerializer.create()` 已有去重逻辑
|
||||
- 查询一小时内是否有相同 `user + link` 的记录
|
||||
- 如果有,调用 `existing.save()` 更新 `viewed_at`(因为 `auto_now=True`)
|
||||
- **问题**:时间窗口是1小时,不是按天
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. 前端修改:`src/hooks/useRecordHistory.ts`
|
||||
|
||||
**目标**:使用 localStorage 实现跨组件/跨导航的按天去重
|
||||
|
||||
**修改内容**:
|
||||
- 添加 `getDailyKey(type, link)` 函数,生成格式为 `history:{type}:{link}:{YYYY-MM-DD}` 的 key
|
||||
- 添加 `isRecordedToday(type, link)` 函数,检查 localStorage 中是否已存在当天记录
|
||||
- 添加 `markAsRecorded(type, link)` 函数,在 localStorage 中标记已记录
|
||||
- 在 `useEffect` 中,先检查 `isRecordedToday`,如果已记录则不发送请求
|
||||
- 发送请求成功后,调用 `markAsRecorded` 标记
|
||||
|
||||
**localStorage 设计**:
|
||||
- Key: `history:{type}:{link}:{YYYY-MM-DD}`
|
||||
- Value: 时间戳或简单标记 `"1"`
|
||||
- 示例: `history:tool:/utility/qrcode-generator:2026-07-28`
|
||||
|
||||
### 2. 后端修改:`history/serializers.py`
|
||||
|
||||
**目标**:将去重逻辑从"一小时内"改为"当天内"
|
||||
|
||||
**修改内容**:
|
||||
- 修改 `BrowsingHistoryCreateSerializer.create()` 方法
|
||||
- 将 `one_hour_ago = timezone.now() - timedelta(hours=1)` 改为当天开始时间
|
||||
- 使用 `viewed_at__date=timezone.now().date()` 匹配当天记录
|
||||
- 查询条件:`user=user, link=link, viewed_at__date=today`
|
||||
- 如果找到当天记录,调用 `existing.save()` 更新 `viewed_at`
|
||||
- 如果没有,创建新记录
|
||||
|
||||
### 3. 不需要修改的文件
|
||||
- `history/models.py` - 模型定义无需修改
|
||||
- `history/views.py` - 视图逻辑无需修改,serializer 已处理去重
|
||||
- `history/urls.py` - URL 配置无需修改
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `src/hooks/useRecordHistory.ts` | 修改 | 添加 localStorage 按天去重逻辑 |
|
||||
| `chunyu_project/history/serializers.py` | 修改 | 将去重时间窗口从1小时改为当天 |
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. **同一天内重复点击测试**
|
||||
- 打开同一个工具/API 页面3次
|
||||
- 检查历史记录页面,应只有1条记录
|
||||
- 检查 localStorage,应有对应的 key
|
||||
|
||||
2. **跨天测试**
|
||||
- 第一天点击工具A,记录历史
|
||||
- 第二天再次点击工具A
|
||||
- 检查历史记录页面,应有2条记录(分别在不同日期)
|
||||
|
||||
3. **不同工具测试**
|
||||
- 同一天内点击工具A、工具B、工具C
|
||||
- 检查历史记录页面,应有3条记录
|
||||
|
||||
4. **localStorage 清理测试**
|
||||
- 确认不会无限增长(每天自动覆盖旧日期的 key)
|
||||
|
||||
## 风险评估
|
||||
- localStorage 被用户清除时,去重会失效,但后端仍有兜底逻辑
|
||||
- 后端修改不影响现有API接口,只是调整了去重时间窗口
|
||||
@@ -0,0 +1,57 @@
|
||||
# 邀请好友功能修复方案
|
||||
|
||||
## 问题分析
|
||||
|
||||
邀请好友页面(`/wallet/invite`)当前显示邀请码和链接,但当用户通过邀请链接注册时,系统未调用 `tasks.track` API 记录邀请行为,导致邀请任务无法完成和领取奖励。
|
||||
|
||||
---
|
||||
|
||||
## 当前状态
|
||||
|
||||
| 项目 | 状态 |
|
||||
|------|------|
|
||||
| 邀请页面 | `src/pages/Wallet/Invite.tsx` |
|
||||
| 邀请 API | `api_request.invite.getCode()` / `getStats()` / `getRecords()` |
|
||||
| 任务追踪 API | `api_request.tasks.track({ action_type, count })` |
|
||||
| 注册流程 | `user_login_or_register` 未传递 `invite_code` |
|
||||
|
||||
---
|
||||
|
||||
## 问题根因
|
||||
|
||||
1. **注册时未传递 invite_code** - 新用户通过邀请链接注册时,前端未将邀请码传递给注册 API
|
||||
2. **未调用 tasks.track** - 注册成功后未记录邀请行为到任务系统
|
||||
|
||||
---
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `authSlice.tsx` | 登录/注册时传递 `invite_code` |
|
||||
| `Invite.tsx` | 注册成功后调用 `tasks.track` |
|
||||
|
||||
### 详细步骤
|
||||
|
||||
#### 1. 从 URL 提取 invite_code
|
||||
|
||||
当用户通过邀请链接(如 `https://chunyu.dev/invite/CY123456` 或带 `?invite=CY123456`)访问时,从 URL 中提取邀请码并存储到 localStorage。
|
||||
|
||||
#### 2. 注册时传递 invite_code
|
||||
|
||||
在 `user_login_or_register` 请求中附加 `invite_code` 参数。
|
||||
|
||||
#### 3. 注册成功后调用 tasks.track
|
||||
|
||||
注册成功后,调用 `api_request.tasks.track({ action_type: 'invite', count: 1 })` 记录邀请行为。
|
||||
|
||||
---
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 访问 `/invite/CY123456` 确认邀请码被正确提取
|
||||
2. 使用新账号注册,确认注册请求包含 `invite_code`
|
||||
3. 注册成功后确认 `tasks.track` 被调用
|
||||
4. 在任务中心确认邀请任务进度更新
|
||||
@@ -0,0 +1,187 @@
|
||||
# 编程学习模块教学内容与资料下载实现计划
|
||||
|
||||
## 需求总结
|
||||
为编程学习模块(Python基础)添加三部分教学内容:
|
||||
1. **文章教学** - Markdown格式的章节内容
|
||||
2. **视频学习** - 章节视频播放
|
||||
3. **资料下载** - 新增功能,支持下载课程资料(代码、PDF等)
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有架构
|
||||
- **Course** → **Chapter** → **ChapterContent**(Markdown内容)
|
||||
- 章节已有 `video_url` 字段,但**缺少资料下载功能**
|
||||
- `CourseLearn.tsx` 已有视频和文档标签页,但无下载入口
|
||||
|
||||
### 需要新增的内容
|
||||
- 5个Python基础章节的完整Markdown教学内容
|
||||
- 视频链接(使用免费教学视频)
|
||||
- **资料下载功能**(新模型 + 前端UI)
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. 新增资料下载模型
|
||||
|
||||
**文件**:`learn/models.py`
|
||||
|
||||
**新增模型** `CourseMaterial`:
|
||||
```python
|
||||
class CourseMaterial(models.Model):
|
||||
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name='materials', verbose_name='章节')
|
||||
title = models.CharField(max_length=200, verbose_name='资料名称')
|
||||
file = models.FileField(upload_to='learn/materials/%Y/%m/', verbose_name='文件')
|
||||
file_type = models.CharField(max_length=20, choices=[('pdf', 'PDF'), ('code', '代码'), ('other', '其他')], default='other')
|
||||
file_size = models.PositiveIntegerField(default=0, verbose_name='文件大小(字节)')
|
||||
download_count = models.PositiveIntegerField(default=0, verbose_name='下载次数')
|
||||
sort_order = models.IntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['sort_order']
|
||||
verbose_name = '课程资料'
|
||||
verbose_name_plural = '课程资料'
|
||||
```
|
||||
|
||||
### 2. 新增资料下载API
|
||||
|
||||
**文件**:`learn/views.py`
|
||||
|
||||
**新增视图**:
|
||||
- `MaterialListView` - 获取章节资料列表
|
||||
- `MaterialDownloadView` - 文件下载接口(增加下载次数统计)
|
||||
|
||||
**文件**:`learn/serializers.py`
|
||||
|
||||
**新增序列化器**:
|
||||
- `MaterialSerializer` - 资料列表序列化
|
||||
|
||||
### 3. 前端资料下载UI
|
||||
|
||||
**文件**:`CourseLearn.tsx`
|
||||
|
||||
**修改内容**:
|
||||
- 新增"资料"标签页,显示当前章节的可下载资料
|
||||
- 资料卡片展示:文件名、类型图标、大小、下载次数
|
||||
- 点击下载按钮触发文件下载
|
||||
- 支持PDF、代码文件、压缩包等类型图标
|
||||
|
||||
### 4. Python基础教学内容
|
||||
|
||||
**文件**:`learn/management/commands/seed_python_content.py`
|
||||
|
||||
**新增种子命令**,为Python课程填充5个章节内容:
|
||||
|
||||
#### 章节1:Python 简介与安装
|
||||
- **文章内容**:Python历史、特点、安装步骤、第一个程序
|
||||
- **视频**:B站Python入门视频链接
|
||||
- **资料**:Python安装包链接、练习题
|
||||
|
||||
#### 章节2:变量与数据类型
|
||||
- **文章内容**:变量命名、数字、字符串、列表、字典、元组
|
||||
- **视频**:数据类型详解视频
|
||||
- **资料**:练习题代码、速查表PDF
|
||||
|
||||
#### 章节3:条件判断与循环
|
||||
- **文章内容**:if/else、for循环、while循环、break/continue
|
||||
- **视频**:流程控制教学视频
|
||||
- **资料**:编程练习题、示例代码
|
||||
|
||||
#### 章节4:函数与模块
|
||||
- **文章内容**:函数定义、参数、返回值、模块导入、标准库
|
||||
- **视频**:函数与模块视频
|
||||
- **资料**:常用模块速查表、练习代码
|
||||
|
||||
#### 章节5:面向对象编程
|
||||
- **文章内容**:类与对象、继承、多态、封装、魔术方法
|
||||
- **视频**:OOP教学视频
|
||||
- **资料**:完整示例代码、OOP设计模式PDF
|
||||
|
||||
### 5. 种子数据更新
|
||||
|
||||
**文件**:`seed_courses.py`
|
||||
|
||||
在创建章节时,同时创建示例 `ChapterContent` 内容(Markdown格式)。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
### 后端(Django)
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `learn/models.py` | 修改 | 新增 `CourseMaterial` 模型 |
|
||||
| `learn/serializers.py` | 修改 | 新增 `MaterialSerializer` |
|
||||
| `learn/views.py` | 修改 | 新增资料列表和下载接口 |
|
||||
| `learn/urls.py` | 修改 | 新增资料相关路由 |
|
||||
| `learn/admin.py` | 修改 | 注册 `CourseMaterial` 模型 |
|
||||
| `learn/management/commands/seed_python_content.py` | 新增 | Python基础内容种子命令 |
|
||||
|
||||
### 前端(React)
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `CourseLearn.tsx` | 修改 | 新增资料下载标签页和UI |
|
||||
| `CourseLearn.css` | 修改 | 资料卡片样式 |
|
||||
| `api/request.ts` | 修改 | 新增资料API调用 |
|
||||
| `locales/zh.json` | 修改 | 新增资料相关文本 |
|
||||
|
||||
## 资料下载功能设计
|
||||
|
||||
### 数据库流程
|
||||
```
|
||||
Chapter → CourseMaterial(1:N)
|
||||
用户点击下载 → API查询文件 → 文件流返回 → download_count + 1
|
||||
```
|
||||
|
||||
### 前端展示
|
||||
```
|
||||
[视频] [文档] [资料] ← 标签页
|
||||
┌─────────────────────────┐
|
||||
│ 📄 Python基础速查表.pdf │
|
||||
│ 📝 1.2 MB | 128次下载 │
|
||||
│ [ 立即下载 ] │
|
||||
├─────────────────────────┤
|
||||
│ 💻 示例代码.zip │
|
||||
│ 📝 856 KB | 89次下载 │
|
||||
│ [ 立即下载 ] │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### 支持的文件类型
|
||||
| 类型 | 扩展名 | 图标 |
|
||||
|------|--------|------|
|
||||
| PDF | .pdf | 📄 |
|
||||
| 代码 | .py, .js, .html, .css | 💻 |
|
||||
| 压缩包 | .zip, .rar, .7z | 📦 |
|
||||
| 文档 | .doc, .docx, .md | 📝 |
|
||||
| 其他 | * | 📎 |
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. **模型迁移**
|
||||
```bash
|
||||
python manage.py makemigrations learn
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
2. **种子数据**
|
||||
```bash
|
||||
python manage.py seed_python_content
|
||||
```
|
||||
|
||||
3. **API测试**
|
||||
- GET /api/learn/materials/?chapter_id=1 - 获取资料列表
|
||||
- GET /api/learn/materials/1/download/ - 下载文件
|
||||
|
||||
4. **前端测试**
|
||||
- 进入Python课程 → 选择章节 → 点击"资料"标签
|
||||
- 显示资料列表 → 点击下载按钮 → 文件下载成功
|
||||
|
||||
## 风险评估
|
||||
- 文件上传需要配置 `MEDIA_ROOT` 和 `MEDIA_URL`
|
||||
- 大文件下载可能需要流式传输
|
||||
- 下载权限控制(是否需要登录)
|
||||
- 文件大小限制和存储配额
|
||||
|
||||
## 后续优化
|
||||
- 资料上传进度条
|
||||
- 批量下载(打包为zip)
|
||||
- 资料搜索功能
|
||||
- 用户上传资料审核
|
||||
@@ -0,0 +1,69 @@
|
||||
# 时间戳工具移动端适配方案
|
||||
|
||||
## 问题分析
|
||||
|
||||
时间戳工具(`/utility/timestamp`)当前仅有一个桌面端组件 `TimestampTool.tsx`,通过 CSS Media Query 适配移动端。存在的问题:
|
||||
|
||||
### 当前移动端问题
|
||||
|
||||
1. **DatePicker 组件不友好** - antd 的 `DatePicker` + `showTime` 在移动端弹窗体验差,日期选择不便
|
||||
2. **双栏布局空间利用率低** - 两列面板在窄屏上内容拥挤
|
||||
3. **时区 Select 下拉框难用** - 7 个选项的下拉选择器在移动端操作困难
|
||||
4. **结果项溢出** - 复制按钮和长文本在小屏上布局错乱
|
||||
5. **触控目标偏小** - 按钮和输入框未针对触摸优化
|
||||
|
||||
## 当前状态
|
||||
|
||||
| 项目 | 状态 |
|
||||
|------|------|
|
||||
| 桌面端组件 | `TimestampTool.tsx` (已有) |
|
||||
| 移动端组件 | ❌ 不存在 |
|
||||
| 路由配置 | App.tsx 未区分移动/桌面端 |
|
||||
| 移动端样式 | 仅依赖 CSS Media Query |
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 方案:创建独立移动端组件
|
||||
|
||||
遵循项目已有模式(如 `RegexToolMobile`、`JsonFormatterMobile`):
|
||||
|
||||
#### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/pages/TimestampTool/TimestampToolMobile.tsx` | 移动端专用组件 |
|
||||
| `src/pages/TimestampTool/TimestampToolMobile.css` | 移动端专用样式 |
|
||||
|
||||
#### 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `src/App.tsx` | 添加 `TimestampToolMobile` 的懒加载和路由条件渲染 |
|
||||
|
||||
### 移动端组件设计要点
|
||||
|
||||
1. **单栏流式布局** - 上下排列,充分利用宽度
|
||||
2. **原生日期选择** - 使用 `<input type="datetime-local">` 替代 antd DatePicker,移动端自动唤起原生选择器
|
||||
3. **时区快速切换** - 使用按钮组/分段控制器替代 Select 下拉
|
||||
4. **大号触控按钮** - 所有按钮最小 44px 高度
|
||||
5. **结果卡片化** - 每个结果独立卡片,易于阅读和操作
|
||||
6. **固定底部操作栏** - 获取当前时间、清空等常用操作固定在底部
|
||||
|
||||
### 核心功能保留
|
||||
|
||||
- 时间戳 → 日期转换(自动识别秒/毫秒)
|
||||
- 日期 → 时间戳转换
|
||||
- 时区切换
|
||||
- 复制结果
|
||||
- 获取当前时间
|
||||
- 清空输入
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 桌面端访问 `/utility/timestamp` 显示原组件
|
||||
2. 移动端(≤768px)访问 `/utility/timestamp` 显示新移动端组件
|
||||
3. 测试时间戳输入转换功能正常
|
||||
4. 测试日期选择功能正常(原生日期选择器)
|
||||
5. 测试时区切换功能正常
|
||||
6. 测试复制功能正常
|
||||
7. 测试暗色模式切换正常
|
||||
Reference in New Issue
Block a user