Merge pull request 'update mico module' (#6) from mac-update into main

Reviewed-on: Jiao77/NovaBlog#6
This commit is contained in:
Jiao77
2026-03-01 13:31:22 +00:00
12 changed files with 1120 additions and 1 deletions

View File

@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react';
interface HeatmapData {
date: string;
count: number;
}
interface HeatmapProps {
userId?: string;
year?: number;
apiBaseUrl?: string;
}
const API_BASE = typeof window !== 'undefined'
? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api')
: 'http://localhost:8080/api';
const COLORS = [
'bg-primary-100 dark:bg-primary-900/30',
'bg-primary-200 dark:bg-primary-800/40',
'bg-primary-300 dark:bg-primary-700/50',
'bg-primary-400 dark:bg-primary-600/60',
'bg-primary-500 dark:bg-primary-500/70',
];
const MONTHS = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
const DAYS = ['日', '一', '二', '三', '四', '五', '六'];
export default function Heatmap({ userId, year = new Date().getFullYear(), apiBaseUrl }: HeatmapProps) {
const baseUrl = apiBaseUrl || API_BASE;
const [data, setData] = useState<HeatmapData[]>([]);
const [loading, setLoading] = useState(true);
const [hoveredCell, setHoveredCell] = useState<{ date: string; count: number } | null>(null);
useEffect(() => {
const fetchHeatmap = async () => {
try {
const params = new URLSearchParams({ year: year.toString() });
if (userId) params.append('user_id', userId);
const response = await fetch(`${baseUrl}/micros/heatmap?${params}`);
if (response.ok) {
const result = await response.json();
setData(result);
}
} catch (error) {
console.error('Failed to fetch heatmap:', error);
} finally {
setLoading(false);
}
};
fetchHeatmap();
}, [userId, year, baseUrl]);
const getDaysInYear = (year: number) => {
const days: Date[] = [];
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days;
};
const getCountForDate = (date: Date): number => {
const dateStr = date.toISOString().split('T')[0];
const item = data.find(d => d.date === dateStr);
return item ? item.count : 0;
};
const getColorClass = (count: number): string => {
if (count === 0) return 'bg-muted dark:bg-muted/50';
if (count <= 2) return COLORS[0];
if (count <= 4) return COLORS[1];
if (count <= 6) return COLORS[2];
if (count <= 8) return COLORS[3];
return COLORS[4];
};
const formatDisplayDate = (date: Date): string => {
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
};
const getWeeksInYear = (year: number) => {
const days = getDaysInYear(year);
const weeks: Date[][] = [];
let currentWeek: Date[] = [];
const firstDay = days[0];
const firstDayOfWeek = firstDay.getDay();
for (let i = 0; i < firstDayOfWeek; i++) {
currentWeek.push(new Date(year, 0, 1 - firstDayOfWeek + i));
}
days.forEach(day => {
if (day.getDay() === 0 && currentWeek.length > 0) {
weeks.push(currentWeek);
currentWeek = [];
}
currentWeek.push(day);
});
if (currentWeek.length > 0) {
weeks.push(currentWeek);
}
return weeks;
};
const weeks = getWeeksInYear(year);
const totalCount = data.reduce((sum, item) => sum + item.count, 0);
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-foreground">{year} </h3>
<span className="text-sm text-muted-foreground"> {totalCount} </span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
{COLORS.map((color, i) => (
<div key={i} className={`w-3 h-3 rounded-sm ${color}`}></div>
))}
<span></span>
</div>
</div>
<div className="overflow-x-auto">
<div className="inline-flex gap-1">
<div className="flex flex-col gap-1 mr-2 text-xs text-muted-foreground">
{DAYS.map((day, i) => (
<div key={i} className="h-3 flex items-center">{i % 2 === 1 ? day : ''}</div>
))}
</div>
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-1">
{DAYS.map((_, dayIndex) => {
const day = week[dayIndex];
if (!day || day.getFullYear() !== year) {
return <div key={dayIndex} className="w-3 h-3"></div>;
}
const count = getCountForDate(day);
const colorClass = getColorClass(count);
return (
<div
key={dayIndex}
className={`w-3 h-3 rounded-sm ${colorClass} cursor-pointer transition-transform hover:scale-125`}
onMouseEnter={() => setHoveredCell({ date: formatDisplayDate(day), count })}
onMouseLeave={() => setHoveredCell(null)}
/>
);
})}
</div>
))}
</div>
</div>
{hoveredCell && (
<div className="text-sm text-muted-foreground">
{hoveredCell.date}{hoveredCell.count}
</div>
)}
<div className="flex gap-4 text-xs text-muted-foreground">
{MONTHS.map((month, i) => (
<span key={i} className="flex-1 text-center">{month}</span>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
interface MicroComposerProps {
onClose?: () => void;
onSuccess?: () => void;
apiBaseUrl?: string;
}
const API_BASE = typeof window !== 'undefined'
? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api')
: 'http://localhost:8080/api';
export default function MicroComposer({ onClose, onSuccess, apiBaseUrl }: MicroComposerProps) {
const baseUrl = apiBaseUrl || API_BASE;
const [content, setContent] = useState('');
const [tags, setTags] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (!content.trim()) {
alert('请输入内容');
return;
}
const token = localStorage.getItem('token');
if (!token) {
alert('请登录后再发布');
return;
}
setSubmitting(true);
try {
const tagList = tags
.split(/[,\s]+/)
.map(t => t.trim())
.filter(t => t.length > 0);
const response = await fetch(`${baseUrl}/micros`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
content: content.trim(),
tags: tagList,
is_public: isPublic,
images: [],
}),
});
if (response.ok) {
setContent('');
setTags('');
setIsPublic(true);
onSuccess?.();
onClose?.();
} else {
const error = await response.json();
alert(error.error || '发布失败');
}
} catch (error) {
console.error('Failed to post:', error);
alert('发布失败,请重试');
} finally {
setSubmitting(false);
}
};
const remainingChars = 2000 - content.length;
return (
<div className="card">
<div className="mb-4">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="分享你的想法..."
className="input min-h-[120px] resize-none"
maxLength={2000}
/>
<div className="flex justify-end mt-1">
<span className={`text-xs ${remainingChars < 100 ? 'text-red-500' : 'text-muted-foreground'}`}>
{remainingChars}
</span>
</div>
</div>
<div className="mb-4">
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="标签(用逗号或空格分隔)"
className="input"
/>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="w-4 h-4 rounded border-border text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-muted-foreground"></span>
</label>
<div className="flex gap-2">
{onClose && (
<button
onClick={onClose}
className="btn-secondary"
disabled={submitting}
>
</button>
)}
<button
onClick={handleSubmit}
className="btn-primary"
disabled={submitting || !content.trim()}
>
{submitting ? '发布中...' : '发布'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,280 @@
import { useState, useEffect, useCallback } from 'react';
interface User {
id: number;
username: string;
nickname: string;
avatar: string;
}
interface MicroPost {
id: number;
content: string;
images: string;
tags: string;
is_public: boolean;
created_at: string;
updated_at: string;
user: User;
like_count: number;
is_liked: boolean;
}
interface MicroListProps {
userId?: string;
onOpenComposer?: () => void;
apiBaseUrl?: string;
}
const API_BASE = typeof window !== 'undefined'
? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api')
: 'http://localhost:8080/api';
export default function MicroList({ userId, onOpenComposer, apiBaseUrl }: MicroListProps) {
const baseUrl = apiBaseUrl || API_BASE;
const [micros, setMicros] = useState<MicroPost[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const fetchMicros = useCallback(async (pageNum: number, append = false) => {
try {
const params = new URLSearchParams({
page: pageNum.toString(),
page_size: '10',
});
if (userId) params.append('user_id', userId);
const token = localStorage.getItem('token');
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${baseUrl}/micros?${params}`, { headers });
if (response.ok) {
const result = await response.json();
if (append) {
setMicros(prev => [...prev, ...result.data]);
} else {
setMicros(result.data);
}
setTotal(result.pagination.total);
setHasMore(result.pagination.page < result.pagination.total_page);
}
} catch (error) {
console.error('Failed to fetch micros:', error);
} finally {
setLoading(false);
}
}, [userId, baseUrl]);
useEffect(() => {
fetchMicros(1);
}, [fetchMicros]);
const loadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchMicros(nextPage, true);
};
const handleLike = async (microId: number) => {
const token = localStorage.getItem('token');
if (!token) {
alert('请登录后再点赞');
return;
}
try {
const response = await fetch(`${baseUrl}/micros/${microId}/like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const result = await response.json();
setMicros(prev => prev.map(m => {
if (m.id === microId) {
return {
...m,
is_liked: result.liked,
like_count: result.liked ? m.like_count + 1 : m.like_count - 1,
};
}
return m;
}));
}
} catch (error) {
console.error('Failed to like:', error);
}
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return date.toLocaleDateString('zh-CN');
};
const parseJSON = (str: string) => {
try {
return JSON.parse(str || '[]');
} catch {
return [];
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-foreground"></h2>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground"> {total} </span>
{onOpenComposer && (
<button
onClick={onOpenComposer}
className="btn-primary text-sm"
>
</button>
)}
</div>
</div>
{micros.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
</div>
) : (
<>
<div className="space-y-4">
{micros.map(micro => {
const images = parseJSON(micro.images);
const tags = parseJSON(micro.tags);
return (
<div key={micro.id} className="card">
<div className="flex gap-3">
<div className="flex-shrink-0">
{micro.user.avatar ? (
<img
src={micro.user.avatar}
alt={micro.user.nickname || micro.user.username}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center text-white font-semibold">
{(micro.user.nickname || micro.user.username).charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-foreground">
{micro.user.nickname || micro.user.username}
</span>
<span className="text-xs text-muted-foreground">
@{micro.user.username}
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">
{formatTime(micro.created_at)}
</span>
</div>
<div className="text-foreground whitespace-pre-wrap break-words mb-3">
{micro.content}
</div>
{images.length > 0 && (
<div className={`grid gap-2 mb-3 ${
images.length === 1 ? 'grid-cols-1' :
images.length === 2 ? 'grid-cols-2' :
'grid-cols-3'
}`}>
{images.map((img: string, i: number) => (
<img
key={i}
src={img}
alt={`图片 ${i + 1}`}
className="rounded-lg object-cover w-full aspect-square"
/>
))}
</div>
)}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{tags.map((tag: string, i: number) => (
<span key={i} className="tag text-xs">
#{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-6 text-muted-foreground">
<button
onClick={() => handleLike(micro.id)}
className={`flex items-center gap-1 transition-colors ${
micro.is_liked ? 'text-red-500' : 'hover:text-red-500'
}`}
>
<svg
className="w-5 h-5"
fill={micro.is_liked ? 'currentColor' : 'none'}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span className="text-sm">{micro.like_count || ''}</span>
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
{hasMore && (
<div className="text-center pt-4">
<button
onClick={loadMore}
className="btn-secondary"
>
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useState, useCallback } from 'react';
import MicroList from './MicroList';
import MicroComposer from './MicroComposer';
import Heatmap from './Heatmap';
interface MicroPageProps {
apiBaseUrl?: string;
}
const API_BASE = typeof window !== 'undefined'
? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api')
: 'http://localhost:8080/api';
export default function MicroPage({ apiBaseUrl }: MicroPageProps) {
const baseUrl = apiBaseUrl || API_BASE;
const [refreshKey, setRefreshKey] = useState(0);
const [heatmapKey, setHeatmapKey] = useState(0);
const handlePostSuccess = useCallback(() => {
setRefreshKey(prev => prev + 1);
setHeatmapKey(prev => prev + 1);
}, []);
return (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
<div className="xl:col-span-2">
<div className="mb-8">
<MicroComposer
apiBaseUrl={baseUrl}
onSuccess={handlePostSuccess}
/>
</div>
<MicroList
key={refreshKey}
apiBaseUrl={baseUrl}
/>
</div>
<div className="xl:col-span-1">
<div className="sticky top-24 space-y-6">
<div className="card">
<Heatmap
key={heatmapKey}
apiBaseUrl={baseUrl}
/>
</div>
<div className="card">
<h3 className="text-lg font-semibold text-foreground mb-4"></h3>
<p className="text-sm text-muted-foreground leading-relaxed">
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,7 +5,7 @@ pubDate: 2024-01-20
author: NovaBlog
tags: [React, 动效, 组件, 教程]
category: 教程
heroImage: /images/react-components.jpg
heroImage: '/images/hello-world.jpg'
---
import AnimatedCard from '../../components/react/AnimatedCard';

View File

@@ -112,6 +112,7 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
<div class="hidden md:flex items-center gap-6">
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors">首页</a>
<a href="/blog" class="text-foreground/70 hover:text-foreground transition-colors">博客</a>
<a href="/micro" class="text-foreground/70 hover:text-foreground transition-colors">微语</a>
<a href="/categories" class="text-foreground/70 hover:text-foreground transition-colors">分类</a>
<a href="/tags" class="text-foreground/70 hover:text-foreground transition-colors">标签</a>
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors">关于</a>
@@ -158,6 +159,7 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
<div class="content-width py-4 flex flex-col gap-3">
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors py-2">首页</a>
<a href="/blog" class="text-foreground/70 hover:text-foreground transition-colors py-2">博客</a>
<a href="/micro" class="text-foreground/70 hover:text-foreground transition-colors py-2">微语</a>
<a href="/categories" class="text-foreground/70 hover:text-foreground transition-colors py-2">分类</a>
<a href="/tags" class="text-foreground/70 hover:text-foreground transition-colors py-2">标签</a>
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors py-2">关于</a>
@@ -187,6 +189,7 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
<h3 class="font-semibold text-lg mb-4">快速链接</h3>
<ul class="space-y-2 text-sm">
<li><a href="/blog" class="text-foreground/60 hover:text-primary-500 transition-colors">全部文章</a></li>
<li><a href="/micro" class="text-foreground/60 hover:text-primary-500 transition-colors">微语</a></li>
<li><a href="/tags" class="text-foreground/60 hover:text-primary-500 transition-colors">标签分类</a></li>
<li><a href="/about" class="text-foreground/60 hover:text-primary-500 transition-colors">关于我</a></li>
<li><a href="/rss.xml" class="text-foreground/60 hover:text-primary-500 transition-colors">RSS 订阅</a></li>

12
src/pages/micro.astro Normal file
View File

@@ -0,0 +1,12 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import MicroPage from '../components/react/MicroPage';
---
<BaseLayout title="微语 - NovaBlog" description="分享生活点滴,记录每一个精彩瞬间">
<div class="py-12">
<div class="content-width">
<MicroPage client:load />
</div>
</div>
</BaseLayout>