2025.11.27.22.40

This commit is contained in:
RUI
2025-11-27 22:43:24 +08:00
parent 5dbb30b32c
commit 0d73d0c63b
20 changed files with 1154 additions and 226 deletions

View File

@@ -12,8 +12,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, FileText, DollarSign, Settings } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, FileText, DollarSign, Settings, Plus, Trash2 } from 'lucide-react';
import { useForm, Controller, useFieldArray } from 'react-hook-form';
import TiptapEditor from './TiptapEditor';
import { toast } from 'sonner';
@@ -57,12 +57,20 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
: '',
: '',
: '',
: ''
: '',
: '',
: '',
: [] as { 属性名: string; 属性值: string }[]
},
: 'published'
}
});
const { fields: extendedFields, append: appendExtended, remove: removeExtended } = useFieldArray({
control,
name: "资源属性.扩展属性"
});
useEffect(() => {
fetchDependencies();
if (mode === 'edit' && articleId) {
@@ -296,6 +304,16 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
<Lock className="w-4 h-4" />
</h3>
<div className="p-4 bg-gray-50 rounded-lg space-y-4 border border-gray-100">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.版本号')} className="h-8 text-sm bg-white" placeholder="v1.0.0" />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.文件大小')} className="h-8 text-sm bg-white" placeholder="100MB" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.下载链接')} className="h-8 text-sm bg-white" />
@@ -310,6 +328,29 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
<Input {...register('资源属性.解压密码')} className="h-8 text-sm bg-white" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> ()</Label>
<Textarea {...register('资源属性.隐藏内容')} className="text-sm bg-white min-h-[60px]" placeholder="仅付费用户可见的内容..." />
</div>
{/* Extended Attributes */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground flex justify-between items-center">
<span></span>
<Button type="button" variant="ghost" size="sm" className="h-5 px-2 text-xs" onClick={() => appendExtended({ : '', : '' })}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</Label>
{extendedFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-center">
<Input {...register(`资源属性.扩展属性.${index}.属性名` as const)} className="h-7 text-xs bg-white flex-1" placeholder="属性名" />
<Input {...register(`资源属性.扩展属性.${index}.属性值` as const)} className="h-7 text-xs bg-white flex-1" placeholder="属性值" />
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => removeExtended(index)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
</div>
</div>
@@ -334,6 +375,7 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
<SelectContent>
<SelectItem value="points"></SelectItem>
<SelectItem value="cash"> (CNY)</SelectItem>
<SelectItem value="membership_free"></SelectItem>
</SelectContent>
</Select>
)}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import Image from 'next/image';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel";
import { cn } from "@/lib/utils";
@@ -62,10 +63,14 @@ export default function HeroBanner({ banners }: HeroBannerProps) {
<CarouselItem key={index}>
<div className="relative h-[500px] w-full overflow-hidden">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 hover:scale-105"
style={{ backgroundImage: `url(${banner.})` }}
>
<div className="absolute inset-0">
<Image
src={banner.}
alt={banner.}
fill
className="object-cover transition-transform duration-700 hover:scale-105"
priority={index === 0}
/>
<div className="absolute inset-0 bg-black/40" /> {/* Overlay */}
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent" /> {/* Gradient Overlay */}
</div>

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -2,9 +2,6 @@ import jwt from 'jsonwebtoken';
import { NextApiRequest, NextApiResponse } from 'next';
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('Please define the JWT_SECRET environment variable inside .env.local');
}
export interface DecodedToken {
userId: string;
@@ -15,8 +12,12 @@ export interface DecodedToken {
}
export function verifyToken(token: string): DecodedToken | null {
if (!JWT_SECRET) {
console.error('JWT_SECRET is not defined');
return null;
}
try {
return jwt.verify(token, JWT_SECRET as string) as unknown as DecodedToken;
return jwt.verify(token, JWT_SECRET) as unknown as DecodedToken;
} catch (error) {
return null;
}

View File

@@ -43,6 +43,10 @@ const UserSchema = new Schema({
* ==================================================================
* 2. Article (文章/资源模型)
* ==================================================================
* 核心更新:
* - 支付方式增加了 'membership_free' (会员免费)。
* - 资源属性增加了 版本、大小、扩展属性。
* - 统计数据增加了 销量、最近售出时间。
*/
const ArticleSchema = new Schema({
// --- 基础信息 ---
@@ -52,7 +56,7 @@ const ArticleSchema = new Schema({
// --- 内容部分 ---
: { type: String },
: { type: String, required: true },
: { type: String, required: true }, // 公开预览内容 或 包含截断标识的完整内容
// --- SEO 专用优化 ---
SEO关键词: { type: [String], default: [] },
@@ -65,14 +69,28 @@ const ArticleSchema = new Schema({
// --- 售卖策略 ---
: { type: Number, default: 0 },
: { type: String, enum: ['points', 'cash'], default: 'points' },
// 【升级】新增 membership_free会员免费普通用户需购买
: { type: String, enum: ['points', 'cash', 'membership_free'], default: 'points' },
// --- 资源交付 (付费后可见) ---
: {
: { type: String },
: { type: String },
: { type: String },
: { type: String }
// 【升级】标准资源字段
: { type: String }, // e.g., "v2.0.1"
: { type: String }, // e.g., "1.5GB"
// 【升级】扩展属性:支持自定义键值对,如 [{ 属性名: "运行环境", 属性值: "Win11" }]
: [{
: { type: String },
: { type: String }
}],
// 【升级】内容隐藏逻辑
// 直接存在这里,和正文完全分离(推荐,更安全)
: { type: String },
},
// --- 数据统计 ---
@@ -81,7 +99,10 @@ const ArticleSchema = new Schema({
: { type: Number, default: 0 },
: { type: Number, default: 0 },
: { type: Number, default: 0 },
: { type: Number, default: 0 }
: { type: Number, default: 0 },
// 【升级】销售统计
: { type: Number, default: 0 },
: { type: Date }
},
// --- 状态控制 ---
@@ -91,6 +112,7 @@ const ArticleSchema = new Schema({
// 复合索引优化
ArticleSchema.index({ createdAt: -1 });
ArticleSchema.index({ 分类ID: 1, 发布状态: 1 });
ArticleSchema.index({ '统计数据.销量': -1 }); // 新增:支持按销量排行
/**

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Users, FileText, ShoppingBag, DollarSign } from 'lucide-react';
import { verifyToken } from '@/lib/auth';
import { User, Article, Order } from '@/models';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
// 定义统计数据接口
interface DashboardStats {
@@ -15,9 +16,26 @@ interface DashboardStats {
}
export default function AdminDashboard({ stats }: { stats: DashboardStats }) {
// 模拟图表数据 (实际应从 API 获取)
const data = [
{ name: 'Jan', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Feb', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Mar', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Apr', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'May', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Jun', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Jul', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Aug', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Sep', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Oct', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Nov', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Dec', total: Math.floor(Math.random() * 5000) + 1000 },
];
return (
<AdminLayout>
<div className="space-y-6">
{/* ... (existing header and stats cards) ... */}
<div>
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">
@@ -82,9 +100,25 @@ export default function AdminDashboard({ stats }: { stats: DashboardStats }) {
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pl-2">
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
(Recharts)
</div>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={data}>
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `¥${value}`}
/>
<Bar dataKey="total" fill="#000000" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="col-span-3">

View File

@@ -67,7 +67,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
// If article is free, everyone has access
// If article is free (price 0), everyone has access
if (article. === 0) {
hasAccess = true;
}
@@ -75,12 +75,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// If not yet accessible and user is logged in, check purchase/membership
if (!hasAccess && userId) {
const user = await User.findById(userId);
const isMember = user?.?. && new Date(user..) > new Date();
// Check Membership
if (user?.?. && new Date(user..) > new Date()) {
// If payment method is 'membership_free', members get access for free
if (isMember && article. === 'membership_free') {
hasAccess = true;
}
// If user is a member, they might have general download privileges (depending on your business logic)
// For now, let's assume 'membership_free' explicitly grants access to members.
// If you have a global "members can download everything" policy, you can add `if (isMember) hasAccess = true;` here.
// Check if purchased
if (!hasAccess) {
const order = await Order.findOne({
@@ -104,7 +110,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!hasAccess) {
// Hide sensitive content
delete articleData.;
if (articleData.) {
// Keep public attributes visible
const publicAttributes = {
版本号: articleData.资源属性.版本号,
文件大小: articleData.资源属性.文件大小,
扩展属性: articleData.资源属性.扩展属性
};
articleData. = publicAttributes;
}
// Optional: Truncate content for preview
articleData. = await processMarkdown(article..substring(0, 300) + '...');
}

View File

@@ -32,6 +32,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ message: '邮箱或密码错误' });
}
// 检查用户是否被封禁
if (user.) {
return res.status(403).json({ message: '该账号已被封禁,请联系管理员' });
}
// 生成 JWT
const token = jwt.sign(
{ userId: user._id, email: user.邮箱, role: user.角色 },

View File

@@ -1,6 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Order, User, MembershipPlan } from '@/models';
import { Order, User, MembershipPlan, Article } from '@/models';
import { alipayService } from '@/lib/alipay';
export const config = {
@@ -71,8 +71,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
order. = 'alipay';
await order.save();
// Update User Membership
// Handle different order types
if (order. === 'buy_membership') {
// Update User Membership
const plan = await MembershipPlan.findById(order.ID);
if (plan) {
const user = await User.findById(order.ID);
@@ -89,6 +90,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await user.save();
}
}
} else if (order. === 'buy_resource') {
// Update Article Sales Stats
const article = await Article.findById(order.ID);
if (article) {
article.. = (article.. || 0) + 1;
article.. = new Date();
await article.save();
}
}
console.log(`Order ${outTradeNo} marked as paid`);

View File

@@ -1,12 +1,16 @@
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Image from 'next/image';
import Link from 'next/link';
import { useAuth } from '@/hooks/useAuth';
import MainLayout from '@/components/layouts/MainLayout';
import CommentSection from '@/components/article/CommentSection';
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Loader2, Lock, Download, Calendar, User as UserIcon, Tag as TagIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Loader2, Lock, Download, Calendar, User as UserIcon, Tag as TagIcon, Share2, Crown, Sparkles, FileText, Eye, ShoppingCart, Copy, Check, Box, HardDrive } from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { toast } from 'sonner';
@@ -22,22 +26,27 @@ interface Article {
头像: string;
};
ID: {
_id: string;
分类名称: string;
};
ID列表: {
标签名称: string;
}[];
价格: number;
: 'points' | 'cash';
: 'points' | 'cash' | 'membership_free';
?: {
下载链接: string;
提取码: string;
解压密码: string;
隐藏内容: string;
版本号?: string;
文件大小?: string;
?: { 属性名: string; 属性值: string }[];
};
createdAt: string;
: {
阅读数: number;
销量: number;
};
}
@@ -46,13 +55,16 @@ export default function ArticleDetail() {
const { id } = router.query;
const { user, loading: authLoading } = useAuth();
const [article, setArticle] = useState<Article | null>(null);
const [recentArticles, setRecentArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [hasAccess, setHasAccess] = useState(false);
const [purchasing, setPurchasing] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
useEffect(() => {
if (id) {
fetchArticle();
fetchRecentArticles();
}
}, [id]);
@@ -74,6 +86,20 @@ export default function ArticleDetail() {
}
};
const fetchRecentArticles = async () => {
try {
const res = await fetch('/api/articles?limit=5');
if (res.ok) {
const data = await res.json();
// Filter out current article if present
const filtered = data.articles.filter((a: Article) => a._id !== id);
setRecentArticles(filtered.slice(0, 5));
}
} catch (error) {
console.error('Failed to fetch recent articles', error);
}
};
const handlePurchase = async () => {
if (!user) {
router.push(`/auth/login?redirect=${encodeURIComponent(router.asPath)}`);
@@ -113,22 +139,43 @@ export default function ArticleDetail() {
}
};
const handleShare = () => {
const url = window.location.href;
navigator.clipboard.writeText(url).then(() => {
toast.success('链接已复制到剪贴板');
}).catch(() => {
toast.error('复制失败');
});
};
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedField(field);
toast.success('复制成功');
setTimeout(() => setCopiedField(null), 2000);
}).catch(() => {
toast.error('复制失败');
});
};
if (loading) {
return (
<MainLayout>
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="space-y-4 mb-8">
<Skeleton className="h-12 w-3/4" />
<div className="flex gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
<div className="container mx-auto px-4 py-8 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-8">
<Skeleton className="h-12 w-3/4" />
<Skeleton className="h-[400px] w-full rounded-xl" />
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</div>
<div className="lg:col-span-1 space-y-6">
<Skeleton className="h-[300px] w-full rounded-xl" />
<Skeleton className="h-[200px] w-full rounded-xl" />
</div>
</div>
<Skeleton className="h-[400px] w-full rounded-xl mb-8" />
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</div>
</MainLayout>
@@ -145,137 +192,271 @@ export default function ArticleDetail() {
keywords: article.标签ID列表?.map(t => t.).join(',')
}}
>
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Article Header */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-primary bg-primary/10 hover:bg-primary/20">
{article.ID?. || '未分类'}
</Badge>
{article.ID列表?.map((tag, index) => (
<Badge key={index} variant="outline" className="text-gray-500">
{tag.}
</Badge>
))}
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Main Content - Left Column (75%) */}
<div className="lg:col-span-3">
{/* Article Header */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-primary bg-primary/10 hover:bg-primary/20">
{article.ID?. || '未分类'}
</Badge>
{article.ID列表?.map((tag, index) => (
<Badge key={index} variant="outline" className="text-gray-500">
{tag.}
</Badge>
))}
</div>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
{article.}
</h1>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
{article.}
</h1>
<div className="flex items-center gap-6 text-sm text-gray-500 border-b border-gray-100 pb-8">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4" />
<span>{article.ID?. || '匿名'}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>{format(new Date(article.createdAt), 'yyyy-MM-dd', { locale: zhCN })}</span>
</div>
<div>
{article.?. || 0}
</div>
</div>
</div>
{/* Cover Image */}
{article. && (
<div className="mb-10 rounded-xl overflow-hidden shadow-lg">
<img
src={article.}
alt={article.}
className="w-full h-auto object-cover max-h-[500px]"
/>
</div>
)}
{/* Article Content */}
<article className="prose prose-lg max-w-none mb-12 prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-primary hover:prose-a:text-primary/80 prose-img:rounded-xl dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: article.正文内容 }} />
</article>
{/* Resource Download / Paywall Section */}
<div className="bg-gray-50 rounded-xl p-8 mb-12 border border-gray-100">
{hasAccess ? (
<div className="space-y-6">
<h3 className="text-xl font-bold flex items-center gap-2 text-green-700">
<Download className="w-6 h-6" />
</h3>
{article. ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="bg-white p-4 rounded-lg border border-gray-200">
<span className="text-gray-500 text-sm block mb-1"></span>
<a
href={article..}
target="_blank"
rel="noopener noreferrer"
className="text-primary font-medium hover:underline break-all"
>
{article..}
</a>
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-8">
<div className="flex items-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4" />
<span>{article.ID?. || '匿名'}</span>
</div>
{article.. && (
<div className="bg-white p-4 rounded-lg border border-gray-200">
<span className="text-gray-500 text-sm block mb-1"></span>
<code className="bg-gray-100 px-2 py-1 rounded text-gray-900 font-mono">
{article..}
</code>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>{format(new Date(article.createdAt), 'yyyy-MM-dd', { locale: zhCN })}</span>
</div>
<div className="flex items-center gap-2">
<Eye className="w-4 h-4" />
<span>{article.?. || 0} </span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleShare} className="text-gray-500 hover:text-primary">
<Share2 className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* Cover Image */}
{article. && (
<div className="mb-10 rounded-xl overflow-hidden shadow-lg relative aspect-video bg-gray-100">
<Image
src={article.}
alt={article.}
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 800px"
/>
</div>
)}
{/* Article Content */}
<article className="prose prose-lg max-w-none mb-12 prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-primary hover:prose-a:text-primary/80 prose-img:rounded-xl dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: article.正文内容 }} />
</article>
{/* Comments Section */}
<CommentSection articleId={article._id} isLoggedIn={!!user} />
</div>
{/* Sidebar - Right Column (25%) */}
<div className="lg:col-span-1 space-y-6">
{/* Resource Card */}
<Card className="border-primary/20 shadow-md overflow-hidden">
<div className="bg-linear-to-r from-primary/10 to-primary/5 p-4 border-b border-primary/10">
<h3 className="font-bold text-lg flex items-center gap-2 text-gray-900">
<Download className="w-5 h-5 text-primary" />
</h3>
</div>
<CardContent className="p-6 space-y-6">
<div className="flex items-baseline justify-between">
<span className="text-sm text-gray-500"></span>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-primary">
{article. > 0 ? `¥${article.}` : '免费'}
</span>
{article. > 0 && <span className="text-xs text-gray-400">/ </span>}
</div>
</div>
{/* Resource Attributes (Version, Size, etc.) */}
{article. && (
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600 bg-gray-50 p-3 rounded-lg border border-gray-100">
{article.. && (
<div className="flex items-center gap-1.5">
<Box className="w-3.5 h-3.5 text-gray-400" />
<span>: {article..}</span>
</div>
)}
{article.. && (
<div className="flex items-center gap-1.5">
<HardDrive className="w-3.5 h-3.5 text-gray-400" />
<span>: {article..}</span>
</div>
)}
{article..?.map((attr, idx) => (
<div key={idx} className="flex items-center gap-1.5 col-span-2">
<TagIcon className="w-3.5 h-3.5 text-gray-400" />
<span>{attr.}: {attr.}</span>
</div>
))}
</div>
)}
{hasAccess ? (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="bg-green-50 text-green-700 px-4 py-3 rounded-lg text-sm flex items-center gap-2 border border-green-100">
<Check className="w-4 h-4" />
</div>
)}
{article.. && (
<div className="bg-white p-4 rounded-lg border border-gray-200">
<span className="text-gray-500 text-sm block mb-1"></span>
<code className="bg-gray-100 px-2 py-1 rounded text-gray-900 font-mono">
{article..}
</code>
{article. && (
<div className="space-y-3">
<Button className="w-full" asChild>
<a href={article..} target="_blank" rel="noopener noreferrer">
<Download className="w-4 h-4 mr-2" />
</a>
</Button>
{article.. && (
<div className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm border border-gray-100">
<span className="text-gray-500 pl-1">: {article..}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyToClipboard(article.!., 'code')}
>
{copiedField === 'code' ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
)}
{article.. && (
<div className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm border border-gray-100">
<span className="text-gray-500 pl-1">: {article..}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyToClipboard(article.!., 'pwd')}
>
{copiedField === 'pwd' ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
)}
{article.. && (
<div className="bg-amber-50 p-3 rounded text-sm border border-amber-100 text-amber-800">
<div className="font-medium mb-1 flex items-center gap-1">
<Lock className="w-3 h-3" />
</div>
<div className="whitespace-pre-wrap text-xs opacity-90">{article..}</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4">
<Button
className="w-full bg-linear-to-r from-primary to-purple-600 hover:from-primary/90 hover:to-purple-600/90 text-white shadow-lg shadow-primary/20"
onClick={handlePurchase}
disabled={purchasing}
>
{purchasing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<ShoppingCart className="w-4 h-4 mr-2" />
)}
</Button>
{article. === 'membership_free' ? (
<div className="text-center">
<Link href="/membership" className="text-xs text-primary font-medium hover:underline flex items-center justify-center gap-1">
<Crown className="w-3 h-3 text-yellow-500" />
()
</Link>
</div>
) : (
<div className="text-center">
<Link href="/membership" className="text-xs text-gray-500 hover:text-primary flex items-center justify-center gap-1">
<Crown className="w-3 h-3 text-yellow-500" />
</Link>
</div>
)}
</div>
)}
<div className="pt-4 border-t border-gray-100 grid grid-cols-2 gap-4 text-center text-sm text-gray-500">
<div>
<div className="text-gray-900 font-medium">{article.?. || 0}</div>
<div className="text-xs mt-1"></div>
</div>
<div>
<div className="text-gray-900 font-medium">{article.?. || 0}</div>
<div className="text-xs mt-1"></div>
</div>
</div>
</CardContent>
</Card>
{/* Recent Posts Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent className="px-0 pb-2">
<div className="flex flex-col">
{recentArticles.length > 0 ? (
recentArticles.map((item, index) => (
<Link
key={item._id}
href={`/article/${item._id}`}
className="group flex items-start gap-3 px-6 py-3 hover:bg-gray-50 transition-colors border-b border-gray-50 last:border-0"
>
<div className="relative w-16 h-12 shrink-0 rounded overflow-hidden bg-gray-100">
{item. ? (
<Image
src={item.}
alt={item.}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="64px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300">
<FileText className="w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 line-clamp-2 group-hover:text-primary transition-colors">
{item.}
</h4>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-400">
<span>{format(new Date(item.createdAt), 'MM-dd')}</span>
</div>
</div>
</Link>
))
) : (
<div className="px-6 py-4 text-sm text-gray-500 text-center">
</div>
)}
</div>
) : (
<p className="text-gray-500"></p>
)}
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-primary" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="lg"
className="px-8"
onClick={handlePurchase}
disabled={purchasing}
>
{purchasing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<span className="mr-2">¥{article.}</span>
)}
</Button>
<Button
size="lg"
variant="outline"
onClick={() => router.push('/membership')}
>
()
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Comments Section */}
<CommentSection articleId={article._id} isLoggedIn={!!user} />
</div>
</MainLayout>
);

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Loader2 } from 'lucide-react';
import { Loader2, Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
@@ -17,7 +17,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
const formSchema = z.object({
email: z.string().email({ message: "请输入有效的邮箱地址" }),
@@ -69,25 +69,59 @@ export default function LoginPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center"></CardTitle>
<CardDescription className="text-center">
</CardDescription>
</CardHeader>
<CardContent>
<div className="min-h-screen grid lg:grid-cols-2">
{/* Left Panel - Visual & Branding */}
<div className="hidden lg:flex flex-col justify-between bg-black text-white p-12 relative overflow-hidden">
{/* Abstract Background */}
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop')] bg-cover bg-center opacity-40"></div>
<div className="absolute inset-0 bg-linear-to-br from-black via-black/80 to-transparent"></div>
{/* Content */}
<div className="relative z-10">
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="w-8 h-8 rounded-lg bg-white text-black flex items-center justify-center">
<Sparkles className="w-5 h-5" />
</div>
AOUN AI
</div>
</div>
<div className="relative z-10 space-y-6 max-w-lg">
<h1 className="text-5xl font-bold leading-tight tracking-tight">
<br />
<span className="text-transparent bg-clip-text bg-linear-to-r from-blue-400 to-purple-400"></span>
</h1>
<p className="text-lg text-gray-300 leading-relaxed">
AI
</p>
</div>
<div className="relative z-10 text-sm text-gray-400">
© 2024 AOUN AI. All rights reserved.
</div>
</div>
{/* Right Panel - Login Form */}
<div className="flex items-center justify-center p-8 bg-white">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold tracking-tight text-gray-900"></h2>
<p className="text-gray-500"></p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input placeholder="name@example.com" className="pl-10 h-11" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
@@ -100,33 +134,67 @@ export default function LoginPage() {
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox id="remember" />
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-500"
>
</label>
</div>
<Link href="#" className="text-sm font-medium text-primary hover:underline">
</Link>
</div>
{error && (
<div className="text-sm text-destructive text-center">{error}</div>
<div className="p-3 rounded-md bg-red-50 text-red-500 text-sm text-center font-medium">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Button type="submit" className="w-full h-11 text-base" disabled={isLoading}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
</span>
)}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-sm text-muted-foreground">
{' '}
<Link href="/auth/register" className="text-primary hover:underline">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-100" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">
</span>
</div>
</div>
<div className="text-center">
<Link href="/auth/register" className="text-primary font-semibold hover:underline">
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Loader2 } from 'lucide-react';
import { Loader2, User, Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -16,7 +16,6 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
const formSchema = z.object({
username: z.string().min(2, { message: "用户名至少需要2个字符" }),
@@ -76,15 +75,46 @@ export default function RegisterPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center"></CardTitle>
<CardDescription className="text-center">
使
</CardDescription>
</CardHeader>
<CardContent>
<div className="min-h-screen grid lg:grid-cols-2">
{/* Left Panel - Visual & Branding */}
<div className="hidden lg:flex flex-col justify-between bg-black text-white p-12 relative overflow-hidden">
{/* Abstract Background */}
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?q=80&w=2574&auto=format&fit=crop')] bg-cover bg-center opacity-40"></div>
<div className="absolute inset-0 bg-linear-to-br from-black via-black/80 to-transparent"></div>
{/* Content */}
<div className="relative z-10">
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="w-8 h-8 rounded-lg bg-white text-black flex items-center justify-center">
<Sparkles className="w-5 h-5" />
</div>
AOUN AI
</div>
</div>
<div className="relative z-10 space-y-6 max-w-lg">
<h1 className="text-5xl font-bold leading-tight tracking-tight">
<br />
<span className="text-transparent bg-clip-text bg-linear-to-r from-purple-400 to-pink-400"></span>
</h1>
<p className="text-lg text-gray-300 leading-relaxed">
AI GPT-4Claude 3
</p>
</div>
<div className="relative z-10 text-sm text-gray-400">
© 2024 AOUN AI. All rights reserved.
</div>
</div>
{/* Right Panel - Register Form */}
<div className="flex items-center justify-center p-8 bg-white">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold tracking-tight text-gray-900"></h2>
<p className="text-gray-500"></p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
@@ -94,7 +124,10 @@ export default function RegisterPage() {
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input placeholder="johndoe" className="pl-10 h-11" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
@@ -105,9 +138,12 @@ export default function RegisterPage() {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input placeholder="name@example.com" className="pl-10 h-11" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
@@ -120,7 +156,10 @@ export default function RegisterPage() {
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
@@ -133,7 +172,10 @@ export default function RegisterPage() {
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
@@ -141,25 +183,41 @@ export default function RegisterPage() {
/>
{error && (
<div className="text-sm text-destructive text-center">{error}</div>
<div className="p-3 rounded-md bg-red-50 text-red-500 text-sm text-center font-medium">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Button type="submit" className="w-full h-11 text-base mt-2" disabled={isLoading}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
</span>
)}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-sm text-muted-foreground">
{' '}
<Link href="/auth/login" className="text-primary hover:underline">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-100" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">
</span>
</div>
</div>
<div className="text-center">
<Link href="/auth/login" className="text-primary font-semibold hover:underline">
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -6,13 +6,56 @@ import Sidebar from '@/components/home/Sidebar';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
interface Article {
_id: string;
文章标题: string;
摘要?: string;
封面图?: string;
ID?: {
用户名: string;
头像: string;
};
ID?: {
分类名称: string;
};
ID列表?: {
标签名称: string;
}[];
: {
阅读数: number;
点赞数: number;
评论数: number;
};
价格?: number;
createdAt: string;
}
interface Category {
_id: string;
分类名称: string;
别名: string;
}
interface Tag {
_id: string;
标签名称: string;
}
interface Banner {
标题: string;
描述: string;
图片地址: string;
按钮文本: string;
按钮链接: string;
}
export default function Home() {
const [articles, setArticles] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [tags, setTags] = useState<any[]>([]);
const [articles, setArticles] = useState<Article[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState('all');
const [banners, setBanners] = useState<any[]>([]);
const [banners, setBanners] = useState<Banner[]>([]);
useEffect(() => {
fetchData();
@@ -108,7 +151,16 @@ export default function Home() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{articles.map(article => (
<ArticleCard key={article._id} article={article} />
<ArticleCard
key={article._id}
article={{
...article,
摘要: article.摘要 || '',
封面图: article.封面图 || '',
价格: article.价格 || 0,
分类ID: article.分类ID || { : '未分类' }
}}
/>
))}
</div>
)}