docs: 添加开发技能文档(skills)
- markdown-render.md: Markdown渲染实现方案 - dashboard-calendar.md: 管理后台首页日历功能实现
This commit is contained in:
parent
8c16de26ad
commit
90a5c032c2
264
.qoder/skills/dashboard-calendar.md
Normal file
264
.qoder/skills/dashboard-calendar.md
Normal file
@ -0,0 +1,264 @@
|
||||
# 管理后台首页日历Skill
|
||||
|
||||
## 适用场景
|
||||
|
||||
需要在管理后台首页展示日历视图,用于:
|
||||
- 工作日志录入情况概览
|
||||
- 考勤打卡情况展示
|
||||
- 日程安排查看
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 按月份展示日历
|
||||
- 日期状态标识(已录入/未录入)
|
||||
- 管理员可选择人员查看
|
||||
- 点击日期查看详情
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 后端接口
|
||||
|
||||
```java
|
||||
// LogController.java
|
||||
@GetMapping("/calendar")
|
||||
public Result<List<String>> getCalendarData(
|
||||
@RequestParam Integer year,
|
||||
@RequestParam Integer month,
|
||||
@RequestParam(required = false) String userId) {
|
||||
return Result.success(logService.getLogDatesByMonth(year, month, userId));
|
||||
}
|
||||
|
||||
// LogService.java
|
||||
List<String> getLogDatesByMonth(Integer year, Integer month, String userId);
|
||||
```
|
||||
|
||||
### 前端实现
|
||||
|
||||
#### 1. 日历组件结构
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 管理员人员选择器 -->
|
||||
<el-card v-if="isAdmin" class="user-selector-card">
|
||||
<el-select v-model="selectedUserId" placeholder="选择人员查看日志" clearable @change="handleUserChange">
|
||||
<el-option label="自己" value="" />
|
||||
<el-option v-for="user in userList" :key="user.id" :label="user.name" :value="user.id" />
|
||||
</el-select>
|
||||
</el-card>
|
||||
|
||||
<!-- 日历卡片 -->
|
||||
<el-card class="calendar-card">
|
||||
<!-- 月份切换 -->
|
||||
<div class="calendar-header">
|
||||
<el-button @click="prevMonth" :icon="ArrowLeft" circle />
|
||||
<span class="month-title">{{ currentYear }}年{{ currentMonth }}月</span>
|
||||
<el-button @click="nextMonth" :icon="ArrowRight" circle />
|
||||
</div>
|
||||
|
||||
<!-- 日历 -->
|
||||
<el-calendar v-model="calendarDate">
|
||||
<template #date-cell="{ data }">
|
||||
<div class="calendar-day" :class="getDayClass(data.day)" @click="handleDayClick(data.day)">
|
||||
<span class="day-number">{{ data.day.split('-')[2] }}</span>
|
||||
<span v-if="logDates.has(data.day)" class="log-indicator has-log">✓</span>
|
||||
<span v-else-if="isCurrentMonth(data.day)" class="log-indicator no-log">○</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-calendar>
|
||||
|
||||
<!-- 图例 -->
|
||||
<div class="calendar-legend">
|
||||
<span class="legend-item"><span class="dot blue"></span>已记录</span>
|
||||
<span class="legend-item"><span class="dot red"></span>未记录</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 2. 核心逻辑
|
||||
|
||||
```typescript
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { getCalendarData, getLogByDate } from '@/api/log'
|
||||
import { listEnabledUsers } from '@/api/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.isAdmin())
|
||||
|
||||
const today = new Date()
|
||||
const currentYear = ref(today.getFullYear())
|
||||
const currentMonth = ref(today.getMonth() + 1)
|
||||
const calendarDate = ref(today)
|
||||
|
||||
const logDates = ref<Set<string>>(new Set())
|
||||
const userList = ref<User[]>([])
|
||||
const selectedUserId = ref<string>('')
|
||||
|
||||
// 加载日历数据
|
||||
async function loadCalendarData() {
|
||||
const dates = await getCalendarData(currentYear.value, currentMonth.value, selectedUserId.value || undefined)
|
||||
logDates.value = new Set(dates)
|
||||
}
|
||||
|
||||
// 月份切换
|
||||
function prevMonth() {
|
||||
if (currentMonth.value === 1) {
|
||||
currentYear.value--
|
||||
currentMonth.value = 12
|
||||
} else {
|
||||
currentMonth.value--
|
||||
}
|
||||
updateCalendarDate()
|
||||
loadCalendarData()
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (currentMonth.value === 12) {
|
||||
currentYear.value++
|
||||
currentMonth.value = 1
|
||||
} else {
|
||||
currentMonth.value++
|
||||
}
|
||||
updateCalendarDate()
|
||||
loadCalendarData()
|
||||
}
|
||||
|
||||
// 判断日期样式
|
||||
function getDayClass(dateStr: string): string {
|
||||
if (!isCurrentMonth(dateStr)) return 'other-month'
|
||||
if (logDates.value.has(dateStr)) return 'has-log'
|
||||
return 'no-log'
|
||||
}
|
||||
|
||||
// 监听日历组件月份变化
|
||||
watch(calendarDate, (newDate) => {
|
||||
const year = newDate.getFullYear()
|
||||
const month = newDate.getMonth() + 1
|
||||
if (year !== currentYear.value || month !== currentMonth.value) {
|
||||
currentYear.value = year
|
||||
currentMonth.value = month
|
||||
loadCalendarData()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 3. 样式规范
|
||||
|
||||
```css
|
||||
/* 日期样式 - 已记录显示蓝色 */
|
||||
.has-log .day-number,
|
||||
.has-log .log-indicator {
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 日期样式 - 未记录显示红色 */
|
||||
.no-log .day-number,
|
||||
.no-log .log-indicator {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
/* 非当月日期 */
|
||||
.other-month .day-number {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.other-month .log-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 日历格子 */
|
||||
.calendar-day {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
```
|
||||
|
||||
## API接口定义
|
||||
|
||||
### 获取日历数据
|
||||
|
||||
```typescript
|
||||
// api/log.ts
|
||||
export function getCalendarData(year: number, month: number, userId?: string): Promise<string[]> {
|
||||
const params: Record<string, string | number> = { year, month }
|
||||
if (userId) params.userId = userId
|
||||
return request.get('/log/calendar', { params })
|
||||
}
|
||||
```
|
||||
|
||||
### 获取日志详情
|
||||
|
||||
```typescript
|
||||
export function getLogByDate(date: string, userId?: string): Promise<Log> {
|
||||
const params: Record<string, string> = { date }
|
||||
if (userId) params.userId = userId
|
||||
return request.get('/log/byDate', { params })
|
||||
}
|
||||
```
|
||||
|
||||
## 管理员权限扩展
|
||||
|
||||
### 后端扩展
|
||||
|
||||
```java
|
||||
// UserService.java
|
||||
List<User> listEnabledUsers();
|
||||
|
||||
// UserServiceImpl.java
|
||||
@Override
|
||||
public List<User> listEnabledUsers() {
|
||||
return userMapper.selectList(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.eq(User::getStatus, 1)
|
||||
.orderByAsc(User::getCreatedTime)
|
||||
);
|
||||
}
|
||||
|
||||
// LogService.java
|
||||
List<String> getLogDatesByMonth(Integer year, Integer month, String userId);
|
||||
|
||||
// LogServiceImpl.java
|
||||
@Override
|
||||
public List<String> getLogDatesByMonth(Integer year, Integer month, String userId) {
|
||||
String targetUserId = StringUtils.hasText(userId) ? userId : getCurrentUserId();
|
||||
// 查询该用户当月的日志日期列表
|
||||
return logMapper.selectDatesByMonth(year, month, targetUserId);
|
||||
}
|
||||
```
|
||||
|
||||
### 前端权限判断
|
||||
|
||||
```typescript
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.isAdmin())
|
||||
```
|
||||
|
||||
## 状态颜色规范
|
||||
|
||||
| 状态 | 颜色 | 说明 |
|
||||
|------|------|------|
|
||||
| 已记录 | #409eff (蓝色) | 当月日期有日志记录 |
|
||||
| 未记录 | #f56c6c (红色) | 当月日期无日志记录 |
|
||||
| 非当月 | #c0c4cc (灰色) | 不属于当前显示月份 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **日期格式**:统一使用 `YYYY-MM-DD` 格式
|
||||
2. **时区处理**:注意前后端时区一致性
|
||||
3. **权限控制**:非管理员只能查看自己的日志
|
||||
4. **性能优化**:日历数据按月加载,避免一次性请求过多
|
||||
257
.qoder/skills/markdown-render.md
Normal file
257
.qoder/skills/markdown-render.md
Normal file
@ -0,0 +1,257 @@
|
||||
# Markdown渲染Skill
|
||||
|
||||
## 适用场景
|
||||
|
||||
当前端页面需要渲染Markdown格式的文本内容时使用,适用于:
|
||||
- 日志详情展示
|
||||
- 文章内容展示
|
||||
- 备注/说明信息展示
|
||||
|
||||
## 实现方案
|
||||
|
||||
使用纯JavaScript实现简单的Markdown渲染,无需引入第三方库。
|
||||
|
||||
### Vue 3 组件实现
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
content?: string
|
||||
}>()
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.content) return '<p class="empty-content">暂无内容</p>'
|
||||
|
||||
let content = props.content
|
||||
|
||||
// 1. 转义HTML特殊字符(安全第一)
|
||||
content = content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// 2. 标题(按优先级从高到低)
|
||||
content = content.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
content = content.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
content = content.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
|
||||
// 3. 粗体和斜体
|
||||
content = content.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
content = content.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
|
||||
// 4. 代码块(多行)和行内代码
|
||||
content = content.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||
content = content.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
|
||||
// 5. 列表
|
||||
content = content.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
content = content.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
|
||||
content = content.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
|
||||
|
||||
// 6. 段落和换行
|
||||
content = content.replace(/\n\n/g, '</p><p>')
|
||||
content = content.replace(/\n/g, '<br>')
|
||||
|
||||
return `<p>${content}</p>`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-body" v-html="renderedContent"></div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 样式规范
|
||||
|
||||
#### 管理后台(Element Plus)
|
||||
|
||||
```css
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3 {
|
||||
margin: 16px 0 8px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background-color: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background-color: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
```
|
||||
|
||||
#### 移动端(Vant)
|
||||
|
||||
```css
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3 {
|
||||
margin: 16px 0 8px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 18px;
|
||||
border-bottom: 1px solid #ebedf0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background-color: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background-color: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
color: #969799;
|
||||
text-align: center;
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的Markdown语法
|
||||
|
||||
| 语法 | Markdown | 渲染结果 |
|
||||
|------|----------|----------|
|
||||
| 一级标题 | `# 标题` | `<h1>标题</h1>` |
|
||||
| 二级标题 | `## 标题` | `<h2>标题</h2>` |
|
||||
| 三级标题 | `### 标题` | `<h3>标题</h3>` |
|
||||
| 粗体 | `**文本**` | `<strong>文本</strong>` |
|
||||
| 斜体 | `*文本*` | `<em>文本</em>` |
|
||||
| 行内代码 | `` `代码` `` | `<code>代码</code>` |
|
||||
| 代码块 | ` ```代码块``` ` | `<pre><code>代码块</code></pre>` |
|
||||
| 无序列表 | `- 列表项` | `<ul><li>列表项</li></ul>` |
|
||||
| 段落换行 | 空行分隔 | `<p>` 标签 |
|
||||
| 普通换行 | 单个换行 | `<br>` |
|
||||
|
||||
## 安全说明
|
||||
|
||||
**重要**:渲染前必须转义HTML特殊字符(`&`, `<`, `>`),防止XSS攻击。
|
||||
|
||||
```javascript
|
||||
// 必须首先执行
|
||||
content = content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 日志详情弹窗
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-dialog v-model="visible" width="700px">
|
||||
<template #header>
|
||||
<div class="log-detail-header">
|
||||
<div class="header-left">
|
||||
<span class="log-date">{{ logDate }}</span>
|
||||
<span class="log-title">{{ title }}</span>
|
||||
</div>
|
||||
<span class="log-user">{{ userName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="log-detail-content">
|
||||
<div class="markdown-body" v-html="renderedContent"></div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **性能考虑**:对于大量内容的渲染,考虑使用`v-memo`或虚拟滚动
|
||||
2. **代码高亮**:如需语法高亮,可额外集成highlight.js或prism.js
|
||||
3. **扩展语法**:当前实现为基础语法,如需表格、引用等可按需扩展
|
||||
Loading…
x
Reference in New Issue
Block a user