feat: 支付闭环 + 运营助手 Tool Calling + 管理后台完善
- 支付系统:微信支付 mock 自动完成、NATIVE 扫码支付、JSAPI 集成 - 运营助手:Tool Calling 架构,19 个可执行工具,AI 驱动操作 - 角色管理:表格布局 + Dialog 表单 + 权限勾选 - 配置统一:config.ts 单一数据源 - API 审计:补齐 status toggle / comments 端点 - 暗黑模式硬件编码颜色全部替换为 CSS 变量
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
import { TrendingUp, TrendingDown, Users, ShoppingCart, DollarSign, BookOpen, MessageCircle } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface OverviewData {
|
||||
users: { total: number; active: number; new: number; growth: number };
|
||||
@@ -36,11 +37,11 @@ export default function AnalyticsPage() {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const base = API_BASE;
|
||||
|
||||
const [ovRes, trRes] = await Promise.all([
|
||||
fetch(`${base}/api/v1/admin/analytics/overview?range=${range}`, { headers }),
|
||||
fetch(`${base}/api/v1/admin/analytics/trend?type=${trendType}&days=${range === 'today' ? 7 : range === '7d' ? 30 : 90}`, { headers }),
|
||||
fetch(`${base}/admin/analytics/overview?range=${range}`, { headers }),
|
||||
fetch(`${base}/admin/analytics/trend?type=${trendType}&days=${range === 'today' ? 7 : range === '7d' ? 30 : 90}`, { headers }),
|
||||
]);
|
||||
|
||||
if (ovRes.ok) setOverview(await ovRes.json());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface PendingComment {
|
||||
id: number;
|
||||
@@ -17,7 +18,7 @@ export default function AdminCommentsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<'pending' | 'approved' | 'rejected'>('pending');
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
|
||||
const base = API_BASE;
|
||||
function headers() {
|
||||
const t = localStorage.getItem('adminToken');
|
||||
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function EditContentPage() {
|
||||
const params = useParams();
|
||||
@@ -17,7 +18,7 @@ export default function EditContentPage() {
|
||||
|
||||
useEffect(() => { loadContent(); }, [params.id]);
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const base = API_BASE;
|
||||
function token() { return localStorage.getItem('adminToken'); }
|
||||
function headers() {
|
||||
const t = token();
|
||||
@@ -26,7 +27,7 @@ export default function EditContentPage() {
|
||||
|
||||
async function loadContent() {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/contents/${params.id}`, { headers: headers() });
|
||||
const res = await fetch(`${base}/contents/${params.id}`, { headers: headers() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setForm({ title: data.title || '', summary: data.summary || '', content: data.content || '', cover: data.cover || '', contentType: data.contentType || 'article' });
|
||||
@@ -40,7 +41,7 @@ export default function EditContentPage() {
|
||||
if (!form.title.trim() || !form.content.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/contents/${params.id}`, {
|
||||
const res = await fetch(`${base}/contents/${params.id}`, {
|
||||
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
||||
});
|
||||
if (res.ok) router.push('/admin/contents');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/contents`);
|
||||
const res = await fetch(`${API_BASE}/contents`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function NewContentPage() {
|
||||
const router = useRouter();
|
||||
@@ -18,7 +19,7 @@ export default function NewContentPage() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/contents`, {
|
||||
const res = await fetch(`${API_BASE}/contents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(form),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Content {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ export default function AdminContents() {
|
||||
async function loadContents() {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/contents?pageSize=50`, {
|
||||
const res = await fetch(`${API_BASE}/contents?pageSize=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -39,7 +40,7 @@ export default function AdminContents() {
|
||||
async function toggleStatus(id: number, currentStatus: string) {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/contents/${id}/status`, {
|
||||
await fetch(`${API_BASE}/admin/contents/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function EditCoursePage() {
|
||||
const params = useParams();
|
||||
@@ -17,7 +18,7 @@ export default function EditCoursePage() {
|
||||
|
||||
useEffect(() => { loadCourse(); }, [params.id]);
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const base = API_BASE;
|
||||
function token() { return localStorage.getItem('adminToken'); }
|
||||
function headers() {
|
||||
const t = token();
|
||||
@@ -26,7 +27,7 @@ export default function EditCoursePage() {
|
||||
|
||||
async function loadCourse() {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/courses/${params.id}`, { headers: headers() });
|
||||
const res = await fetch(`${base}/courses/${params.id}`, { headers: headers() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setForm({ title: data.title || '', description: data.description || '', cover: data.cover || '', isFree: data.isFree ?? true });
|
||||
@@ -40,7 +41,7 @@ export default function EditCoursePage() {
|
||||
if (!form.title.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/courses/${params.id}`, {
|
||||
const res = await fetch(`${base}/courses/${params.id}`, {
|
||||
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
||||
});
|
||||
if (res.ok) router.push('/admin/courses');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/courses`);
|
||||
const res = await fetch(`${API_BASE}/courses`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function NewCoursePage() {
|
||||
const router = useRouter();
|
||||
@@ -18,7 +19,7 @@ export default function NewCoursePage() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/courses`, {
|
||||
const res = await fetch(`${API_BASE}/courses`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(form),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Course {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ export default function AdminCourses() {
|
||||
async function loadCourses() {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/courses?pageSize=50`, {
|
||||
const res = await fetch(`${API_BASE}/courses?pageSize=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -39,7 +40,7 @@ export default function AdminCourses() {
|
||||
async function toggleStatus(id: number, currentStatus: string) {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/courses/${id}/status`, {
|
||||
await fetch(`${API_BASE}/admin/courses/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function OrgDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -27,11 +28,11 @@ export default function OrgDetailPage() {
|
||||
|
||||
useEffect(() => { loadOrg(); loadCourses(); }, [orgId]);
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const base = API_BASE;
|
||||
|
||||
async function loadOrg() {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}`, { headers: headers() });
|
||||
const res = await fetch(`${base}/enterprise/organizations/${orgId}`, { headers: headers() });
|
||||
if (res.ok) setOrg(await res.json());
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
@@ -39,7 +40,7 @@ export default function OrgDetailPage() {
|
||||
|
||||
async function loadCourses() {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/courses`, { headers: headers() });
|
||||
const res = await fetch(`${base}/courses`, { headers: headers() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCourses(data.items || []);
|
||||
@@ -51,7 +52,7 @@ export default function OrgDetailPage() {
|
||||
const uid = Number(memberUserId);
|
||||
if (!uid) return;
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/members`, {
|
||||
const res = await fetch(`${base}/enterprise/organizations/${orgId}/members`, {
|
||||
method: 'POST', headers: headers(),
|
||||
body: JSON.stringify({ userId: uid }),
|
||||
});
|
||||
@@ -65,7 +66,7 @@ export default function OrgDetailPage() {
|
||||
|
||||
async function removeMember(userId: number) {
|
||||
try {
|
||||
await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/members/${userId}`, {
|
||||
await fetch(`${base}/enterprise/organizations/${orgId}/members/${userId}`, {
|
||||
method: 'DELETE', headers: headers(),
|
||||
});
|
||||
loadOrg();
|
||||
@@ -78,7 +79,7 @@ export default function OrgDetailPage() {
|
||||
try {
|
||||
const body: any = { courseId: cid };
|
||||
if (courseDeadline) body.deadline = courseDeadline;
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/assignments`, {
|
||||
const res = await fetch(`${base}/enterprise/organizations/${orgId}/assignments`, {
|
||||
method: 'POST', headers: headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
@@ -93,7 +94,7 @@ export default function OrgDetailPage() {
|
||||
|
||||
async function updateOrg() {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}`, {
|
||||
const res = await fetch(`${base}/enterprise/organizations/${orgId}`, {
|
||||
method: 'PUT', headers: headers(),
|
||||
body: JSON.stringify(editForm),
|
||||
});
|
||||
@@ -106,7 +107,7 @@ export default function OrgDetailPage() {
|
||||
|
||||
async function removeAssignment(courseId: number) {
|
||||
try {
|
||||
await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/assignments/${courseId}`, {
|
||||
await fetch(`${base}/enterprise/organizations/${orgId}/assignments/${courseId}`, {
|
||||
method: 'DELETE', headers: headers(),
|
||||
});
|
||||
loadOrg();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations`);
|
||||
const res = await fetch(`${API_BASE}/enterprise/organizations`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations`);
|
||||
const res = await fetch(`${API_BASE}/enterprise/organizations`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function OrgReportPage() {
|
||||
const params = useParams();
|
||||
@@ -19,11 +20,11 @@ export default function OrgReportPage() {
|
||||
|
||||
useEffect(() => { loadReport(); }, [orgId]);
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const base = API_BASE;
|
||||
|
||||
async function loadReport() {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/report`, { headers: headers() });
|
||||
const res = await fetch(`${base}/enterprise/organizations/${orgId}/report`, { headers: headers() });
|
||||
if (res.ok) setReport(await res.json());
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Organization {
|
||||
id: number; name: string; description?: string;
|
||||
@@ -30,8 +31,8 @@ export default function EnterprisePage() {
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations`, { headers: headers() });
|
||||
const base = API_BASE;
|
||||
const res = await fetch(`${base}/enterprise/organizations`, { headers: headers() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOrgs(data.items || []);
|
||||
@@ -43,8 +44,8 @@ export default function EnterprisePage() {
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/enterprise/organizations`, {
|
||||
const base = API_BASE;
|
||||
const res = await fetch(`${base}/enterprise/organizations`, {
|
||||
method: 'POST', headers: headers(),
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
@@ -6,8 +6,7 @@ import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ export default function BannersPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/banners`, {
|
||||
const res = await fetch(`${API_BASE}/admin/banners`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -38,7 +39,7 @@ export default function BannersPage() {
|
||||
async function createBanner() {
|
||||
if (!form.title || !form.image) return alert('请填写标题和图片');
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/banners`, {
|
||||
await fetch(`${API_BASE}/admin/banners`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ ...form, status: 'PUBLISHED' }),
|
||||
@@ -51,7 +52,7 @@ export default function BannersPage() {
|
||||
async function deleteBanner(id: number) {
|
||||
if (!confirm('确定删除?')) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/banners/${id}`, {
|
||||
await fetch(`${API_BASE}/admin/banners/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ export default function NotificationsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/notifications`, {
|
||||
const res = await fetch(`${API_BASE}/admin/notifications`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -38,7 +39,7 @@ export default function NotificationsPage() {
|
||||
async function sendNotification() {
|
||||
if (!form.title || !form.content) return alert('请填写标题和内容');
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/notifications`, {
|
||||
await fetch(`${API_BASE}/admin/notifications`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ ...form, status: 'SENT' }),
|
||||
@@ -51,7 +52,7 @@ export default function NotificationsPage() {
|
||||
async function deleteNotification(id: number) {
|
||||
if (!confirm('确定删除?')) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/notifications/${id}`, {
|
||||
await fetch(`${API_BASE}/admin/notifications/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Order {
|
||||
id: number;
|
||||
@@ -23,7 +24,7 @@ export default function AdminOrders() {
|
||||
async function loadOrders() {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/orders`, {
|
||||
const res = await fetch(`${API_BASE}/orders`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -39,7 +40,7 @@ export default function AdminOrders() {
|
||||
if (!confirm('确认要退款吗?')) return;
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/payment/wxpay/refund`, {
|
||||
await fetch(`${API_BASE}/payment/wxpay/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Stats {
|
||||
totalUsers: number;
|
||||
@@ -40,7 +41,7 @@ export default function AdminDashboard() {
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/dashboard`, {
|
||||
const res = await fetch(`${API_BASE}/admin/dashboard`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function EditPromptPage() {
|
||||
const params = useParams();
|
||||
@@ -17,7 +18,7 @@ export default function EditPromptPage() {
|
||||
|
||||
useEffect(() => { loadPrompt(); }, [params.id]);
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const base = API_BASE;
|
||||
function token() { return localStorage.getItem('adminToken'); }
|
||||
function headers() {
|
||||
const t = token();
|
||||
@@ -26,7 +27,7 @@ export default function EditPromptPage() {
|
||||
|
||||
async function loadPrompt() {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/prompts/${params.id}`, { headers: headers() });
|
||||
const res = await fetch(`${base}/prompts/${params.id}`, { headers: headers() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setForm({ title: data.title || '', content: data.content || '', description: data.description || '', tags: data.tags || '', model: data.model || '' });
|
||||
@@ -40,7 +41,7 @@ export default function EditPromptPage() {
|
||||
if (!form.title.trim() || !form.content.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/prompts/${params.id}`, {
|
||||
const res = await fetch(`${base}/prompts/${params.id}`, {
|
||||
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
||||
});
|
||||
if (res.ok) router.push('/admin/prompts');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/prompts`);
|
||||
const res = await fetch(`${API_BASE}/prompts`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function NewPromptPage() {
|
||||
const router = useRouter();
|
||||
@@ -18,7 +19,7 @@ export default function NewPromptPage() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/prompts`, {
|
||||
const res = await fetch(`${API_BASE}/prompts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(form),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Prompt {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ export default function AdminPrompts() {
|
||||
async function loadPrompts() {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/prompts?pageSize=50`, {
|
||||
const res = await fetch(`${API_BASE}/prompts?pageSize=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -39,7 +40,7 @@ export default function AdminPrompts() {
|
||||
async function toggleStatus(id: number, currentStatus: string) {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/prompts/${id}/status`, {
|
||||
await fetch(`${API_BASE}/admin/prompts/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Config {
|
||||
key: string;
|
||||
@@ -21,7 +22,7 @@ export default function ConfigPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/config/${category}`, {
|
||||
const res = await fetch(`${API_BASE}/admin/config/${category}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -37,7 +38,7 @@ export default function ConfigPage() {
|
||||
|
||||
async function saveConfig(key: string) {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/config/${key}`, {
|
||||
await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ value: form[key] }),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import * as Dialog from '@/components/ui/dialog';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
@@ -17,6 +22,20 @@ interface Permission {
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface Admin {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
roleId: string | null;
|
||||
role: { id: string; name: string } | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
export default function SettingsRolesPage() {
|
||||
const router = useRouter();
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
@@ -24,213 +43,385 @@ export default function SettingsRolesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<'roles' | 'admins'>('roles');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
const headers = getAuthHeaders();
|
||||
const [rolesRes, permsRes] = await Promise.all([
|
||||
fetch(`${base}/api/v1/admin/settings/roles`, { headers }),
|
||||
fetch(`${base}/api/v1/admin/settings/permissions`, { headers }),
|
||||
fetch(`${API_BASE}/admin/settings/roles`, { headers }),
|
||||
fetch(`${API_BASE}/admin/settings/permissions`, { headers }),
|
||||
]);
|
||||
|
||||
if (rolesRes.ok) {
|
||||
const data = await rolesRes.json();
|
||||
setRoles(data.items || []);
|
||||
}
|
||||
if (permsRes.ok) {
|
||||
const data = await permsRes.json();
|
||||
setPermissions(data.items || []);
|
||||
}
|
||||
} catch {}
|
||||
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
|
||||
if (permsRes.ok) setPermissions((await permsRes.json()).items || []);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function createRole() {
|
||||
const name = prompt('请输入角色名称:');
|
||||
if (!name) return;
|
||||
const desc = prompt('请输入角色描述:') || '';
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name, description: desc }),
|
||||
});
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function deleteRole(id: string) {
|
||||
if (!confirm('确定要删除这个角色吗?')) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/roles/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
loadData();
|
||||
}
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const categories = [...new Set(permissions.map(p => p.category))];
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6">加载中...</div>;
|
||||
return <div className="p-6 text-center text-muted-foreground">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">角色权限管理</h1>
|
||||
<p className="text-sm text-muted-foreground">管理系统角色和权限配置</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">角色权限管理</h1>
|
||||
<p className="mt-2 text-muted-foreground">管理系统角色、权限和管理员</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={createRole}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
新建角色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<button
|
||||
onClick={() => setTab('roles')}
|
||||
className={`px-4 py-2 rounded-lg ${tab === 'roles' ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
角色管理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('admins')}
|
||||
className={`px-4 py-2 rounded-lg ${tab === 'admins' ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
管理员
|
||||
</button>
|
||||
<Button variant={tab === 'roles' ? 'default' : 'outline'} onClick={() => setTab('roles')}>角色管理</Button>
|
||||
<Button variant={tab === 'admins' ? 'default' : 'outline'} onClick={() => setTab('admins')}>管理员</Button>
|
||||
</div>
|
||||
|
||||
{tab === 'roles' ? (
|
||||
<div className="space-y-4">
|
||||
{roles.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">暂无角色</div>
|
||||
) : (
|
||||
roles.map(role => (
|
||||
<div key={role.id} className="bg-card border border-border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{role.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{role.description || '暂无描述'}</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(role.permissions || []).map((p: string) => (
|
||||
<span key={p} className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
|
||||
{permissions.find(perm => perm.key === p)?.name || p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteRole(role.id)}
|
||||
className="text-red-500 hover:text-red-700 text-sm"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<RolesTab roles={roles} permissions={permissions} categories={categories} onReload={loadData} />
|
||||
) : (
|
||||
<AdminsList />
|
||||
<AdminsTab onReload={loadData} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminsList() {
|
||||
const [admins, setAdmins] = useState<any[]>([]);
|
||||
const [roles, setRoles] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
const [adminsRes, rolesRes] = await Promise.all([
|
||||
fetch(`${base}/api/v1/admin/settings/admins`, { headers }),
|
||||
fetch(`${base}/api/v1/admin/settings/roles`, { headers }),
|
||||
]);
|
||||
|
||||
if (adminsRes.ok) {
|
||||
const data = await adminsRes.json();
|
||||
setAdmins(data.items || []);
|
||||
}
|
||||
if (rolesRes.ok) {
|
||||
const data = await rolesRes.json();
|
||||
setRoles(data.items || []);
|
||||
}
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function createAdmin() {
|
||||
const username = prompt('请输入管理员用户名:');
|
||||
if (!username) return;
|
||||
const password = prompt('请输入密码:');
|
||||
if (!password) return;
|
||||
const nickname = prompt('请输入昵称(可选):') || '';
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/admins`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ username, password, nickname }),
|
||||
});
|
||||
loadData();
|
||||
}
|
||||
|
||||
if (loading) return <div>加载中...</div>;
|
||||
function RolesTab({ roles, permissions, categories, onReload }: {
|
||||
roles: Role[]; permissions: Permission[]; categories: string[]; onReload: () => void;
|
||||
}) {
|
||||
const [editRole, setEditRole] = useState<Role | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={createAdmin}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 mb-4"
|
||||
>
|
||||
新建管理员
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
{admins.map(admin => (
|
||||
<div key={admin.id} className="bg-card border border-border rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{admin.username}</div>
|
||||
<div className="text-sm text-muted-foreground">{admin.nickname || '暂无昵称'} · {admin.role?.name || '未分配角色'}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm('确定要禁用这个管理员吗?')) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/admins/${admin.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ status: 'DISABLED' }),
|
||||
});
|
||||
loadData();
|
||||
}}
|
||||
className="text-red-500 hover:text-red-700 text-sm"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => { setEditRole(null); setOpen(true); }}>新建角色</Button>
|
||||
</div>
|
||||
|
||||
<RoleDialog
|
||||
role={editRole}
|
||||
permissions={permissions}
|
||||
categories={categories}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onSaved={() => { setOpen(false); onReload(); }}
|
||||
/>
|
||||
|
||||
{roles.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">暂无角色</div>
|
||||
) : (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="text-left px-4 py-3 font-medium text-foreground">角色名称</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-foreground">描述</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-foreground">权限</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-foreground">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map(role => (
|
||||
<tr key={role.id} className="border-b border-border hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium text-foreground">{role.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{role.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(role.permissions || []).length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">无权限</span>
|
||||
) : (
|
||||
role.permissions.map(p => (
|
||||
<Badge key={p} variant="secondary" className="text-xs">
|
||||
{permissions.find(perm => perm.key === p)?.name || p}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setEditRole(role); setOpen(true); }}>编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700"
|
||||
onClick={async () => {
|
||||
if (!confirm('确定要删除此角色吗?')) return;
|
||||
await fetch(`${API_BASE}/admin/settings/roles/${role.id}`, { method: 'DELETE', headers: getAuthHeaders() });
|
||||
onReload();
|
||||
}}
|
||||
>删除</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleDialog({ role, permissions, categories, open, onOpenChange, onSaved }: {
|
||||
role: Role | null; permissions: Permission[]; categories: string[]; open: boolean; onOpenChange: (v: boolean) => void; onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(role?.name || '');
|
||||
setDescription(role?.description || '');
|
||||
setSelectedPerms(role?.permissions || []);
|
||||
}
|
||||
}, [open, role]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
if (role) {
|
||||
await fetch(`${API_BASE}/admin/settings/roles/${role.id}`, {
|
||||
method: 'PUT', headers,
|
||||
body: JSON.stringify({ name, description, permissions: selectedPerms }),
|
||||
});
|
||||
} else {
|
||||
await fetch(`${API_BASE}/admin/settings/roles`, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ name, description, permissions: selectedPerms }),
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) { console.error(e); }
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.DialogContent>
|
||||
<Dialog.DialogHeader>
|
||||
<Dialog.DialogTitle>{role ? '编辑角色' : '新建角色'}</Dialog.DialogTitle>
|
||||
</Dialog.DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">角色名称</label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="请输入角色名称" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">描述</label>
|
||||
<textarea
|
||||
className="flex w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm"
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="请输入角色描述"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">权限设置</label>
|
||||
{categories.map(cat => (
|
||||
<div key={cat} className="mb-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">{cat}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{permissions.filter(p => p.category === cat).map(perm => (
|
||||
<label key={perm.key} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPerms.includes(perm.key)}
|
||||
onChange={e => {
|
||||
setSelectedPerms(prev =>
|
||||
e.target.checked
|
||||
? [...prev, perm.key]
|
||||
: prev.filter(k => k !== perm.key)
|
||||
);
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{perm.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim() || saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</Dialog.DialogFooter>
|
||||
</Dialog.DialogContent>
|
||||
</Dialog.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminsTab({ onReload }: { onReload: () => void }) {
|
||||
const [admins, setAdmins] = useState<Admin[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editAdmin, setEditAdmin] = useState<Admin | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const [adminsRes, rolesRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/admin/settings/admins`, { headers }),
|
||||
fetch(`${API_BASE}/admin/settings/roles`, { headers }),
|
||||
]);
|
||||
if (adminsRes.ok) setAdmins((await adminsRes.json()).items || []);
|
||||
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
if (loading) return <div className="text-center py-8 text-muted-foreground">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => { setEditAdmin(null); setOpen(true); }}>新建管理员</Button>
|
||||
</div>
|
||||
|
||||
<AdminDialog
|
||||
admin={editAdmin}
|
||||
roles={roles}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onSaved={() => { setOpen(false); loadData(); onReload(); }}
|
||||
/>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="text-left px-4 py-3 font-medium text-foreground">用户名</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-foreground">昵称</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-foreground">角色</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-foreground">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{admins.map(admin => (
|
||||
<tr key={admin.id} className="border-b border-border hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium text-foreground">{admin.username}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{admin.nickname || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="secondary" className="text-xs">{admin.role?.name || '未分配'}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setEditAdmin(admin); setOpen(true); }}>编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700"
|
||||
onClick={async () => {
|
||||
if (!confirm('确定要禁用此管理员吗?')) return;
|
||||
await fetch(`${API_BASE}/admin/settings/admins/${admin.id}`, {
|
||||
method: 'PUT', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ status: 'DISABLED' }),
|
||||
});
|
||||
loadData();
|
||||
}}
|
||||
>禁用</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{admins.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-center py-8 text-muted-foreground">暂无管理员</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function AdminDialog({ admin, roles, open, onOpenChange, onSaved }: {
|
||||
admin: Admin | null; roles: Role[]; open: boolean; onOpenChange: (v: boolean) => void; onSaved: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [roleId, setRoleId] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUsername(admin?.username || '');
|
||||
setPassword('');
|
||||
setNickname(admin?.nickname || '');
|
||||
setRoleId(admin?.roleId || '');
|
||||
}
|
||||
}, [open, admin]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!username.trim()) return;
|
||||
if (!admin && !password.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
if (admin) {
|
||||
await fetch(`${API_BASE}/admin/settings/admins/${admin.id}`, {
|
||||
method: 'PUT', headers,
|
||||
body: JSON.stringify({ nickname, roleId: roleId || null }),
|
||||
});
|
||||
} else {
|
||||
await fetch(`${API_BASE}/admin/settings/admins`, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ username, password, nickname, roleId: roleId || undefined }),
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) { console.error(e); }
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.DialogContent>
|
||||
<Dialog.DialogHeader>
|
||||
<Dialog.DialogTitle>{admin ? '编辑管理员' : '新建管理员'}</Dialog.DialogTitle>
|
||||
</Dialog.DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">用户名</label>
|
||||
<Input value={username} onChange={e => setUsername(e.target.value)} placeholder="登录用户名"
|
||||
disabled={!!admin} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{admin ? '新密码(留空不修改)' : '密码'}</label>
|
||||
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={admin ? '留空则不修改' : '请输入密码'} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">昵称</label>
|
||||
<Input value={nickname} onChange={e => setNickname(e.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">角色</label>
|
||||
<select
|
||||
className="flex w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm"
|
||||
value={roleId}
|
||||
onChange={e => setRoleId(e.target.value)}
|
||||
>
|
||||
<option value="">无角色</option>
|
||||
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={!username.trim() || (!admin && !password.trim()) || saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</Dialog.DialogFooter>
|
||||
</Dialog.DialogContent>
|
||||
</Dialog.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function EditToolPage() {
|
||||
const params = useParams();
|
||||
@@ -17,7 +18,7 @@ export default function EditToolPage() {
|
||||
|
||||
useEffect(() => { loadTool(); }, [params.id]);
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const base = API_BASE;
|
||||
function token() { return localStorage.getItem('adminToken'); }
|
||||
function headers() {
|
||||
const t = token();
|
||||
@@ -26,7 +27,7 @@ export default function EditToolPage() {
|
||||
|
||||
async function loadTool() {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/tools`, { headers: headers() });
|
||||
const res = await fetch(`${base}/tools`, { headers: headers() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const tool = (data.items || []).find((t: any) => t.id === Number(params.id));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/tools`);
|
||||
const res = await fetch(`${API_BASE}/tools`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export default function NewToolPage() {
|
||||
const router = useRouter();
|
||||
@@ -18,7 +19,7 @@ export default function NewToolPage() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/tools`, {
|
||||
const res = await fetch(`${API_BASE}/tools`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(form),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Tool {
|
||||
id: number;
|
||||
@@ -23,7 +24,7 @@ export default function AdminTools() {
|
||||
async function loadTools() {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/tools?pageSize=50`, {
|
||||
const res = await fetch(`${API_BASE}/tools?pageSize=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -38,7 +39,7 @@ export default function AdminTools() {
|
||||
async function toggleStatus(id: number, currentStatus: string) {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/tools/${id}/status`, {
|
||||
await fetch(`${API_BASE}/admin/tools/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
@@ -27,7 +28,7 @@ export default function UsersPage() {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const params = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users${params}`, {
|
||||
const res = await fetch(`${API_BASE}/admin/users${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -41,7 +42,7 @@ export default function UsersPage() {
|
||||
async function createUser() {
|
||||
if (!form.phone || !form.password) return alert('手机号和密码必填');
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users`, {
|
||||
await fetch(`${API_BASE}/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(form),
|
||||
@@ -54,7 +55,7 @@ export default function UsersPage() {
|
||||
async function updateUser() {
|
||||
if (!editId) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${editId}`, {
|
||||
await fetch(`${API_BASE}/admin/users/${editId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ nickname: form.nickname, email: form.email }),
|
||||
@@ -68,7 +69,7 @@ export default function UsersPage() {
|
||||
async function deleteUser(id: number) {
|
||||
if (!confirm('确定删除该用户?')) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${id}`, {
|
||||
await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
@@ -78,7 +79,7 @@ export default function UsersPage() {
|
||||
async function toggleStatus(id: number, currentStatus: string) {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const newStatus = currentStatus === 'ACTIVE' ? 'BANNED' : 'ACTIVE';
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${id}`, {
|
||||
await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
|
||||
@@ -10,8 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
function AuthForm() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Post {
|
||||
id: number;
|
||||
@@ -36,8 +37,8 @@ export default function CircleDetail() {
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const [circleRes, postsRes] = await Promise.all([
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}`, { headers }),
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/posts`, { headers }),
|
||||
fetch(`${API_BASE}/circles/${circleId}`, { headers }),
|
||||
fetch(`${API_BASE}/circles/${circleId}/posts`, { headers }),
|
||||
]);
|
||||
|
||||
if (circleRes.ok) setCircle(await circleRes.json());
|
||||
@@ -47,7 +48,7 @@ export default function CircleDetail() {
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const memRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/membership`, {
|
||||
const memRes = await fetch(`${API_BASE}/circles/${circleId}/membership`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (memRes.ok) {
|
||||
@@ -65,8 +66,8 @@ export default function CircleDetail() {
|
||||
|
||||
const method = isMember ? 'POST' : 'POST';
|
||||
const url = isMember
|
||||
? `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/leave`
|
||||
: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/join`;
|
||||
? `${API_BASE}/circles/${circleId}/leave`
|
||||
: `${API_BASE}/circles/${circleId}/join`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -85,7 +86,7 @@ export default function CircleDetail() {
|
||||
if (!token || !formTitle || !formContent) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/community/posts`, {
|
||||
const res = await fetch(`${API_BASE}/community/posts`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: formTitle, content: formContent, circleId }),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/circles`);
|
||||
const res = await fetch(`${API_BASE}/circles`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Circle {
|
||||
id: number;
|
||||
@@ -21,7 +22,7 @@ export default function CirclesPage() {
|
||||
|
||||
async function loadCircles() {
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles`);
|
||||
const res = await fetch(`${API_BASE}/circles`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCircles(data || []);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/community/posts`);
|
||||
const res = await fetch(`${API_BASE}/community/posts`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Content {
|
||||
id: number; title: string; summary?: string; content?: string; cover?: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/contents`);
|
||||
const res = await fetch(`${API_BASE}/contents`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -6,8 +6,7 @@ import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { FileText, Eye } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Content {
|
||||
id: number; title: string; summary: string | null; cover: string | null;
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Lesson {
|
||||
id: number; title: string; content?: string; sortOrder: number; status: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/courses`);
|
||||
const res = await fetch(`${API_BASE}/courses`);
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) return [{ id: '1' }];
|
||||
|
||||
@@ -6,8 +6,7 @@ import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { BookOpen, Users } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Course {
|
||||
id: number; title: string; description: string; cover: string | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { RootLayoutClient } from './layout-client';
|
||||
import { SITE_URL } from '@/lib/config';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -20,7 +21,7 @@ export const metadata: Metadata = {
|
||||
siteName: '宇之然 AI',
|
||||
title: '宇之然 AI - AI 工具与知识社区',
|
||||
description: '让每个人都能用好 AI',
|
||||
url: 'https://yuzhiran.com',
|
||||
url: SITE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface AiModel {
|
||||
id: number;
|
||||
|
||||
@@ -4,7 +4,19 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../lib/auth';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import PaymentModal from '@/components/ui/payment-modal';
|
||||
import { useT } from '@/i18n';
|
||||
import { isWeChatBrowser, getOpenidFromUrl, isMiniProgram } from '@/lib/wechat';
|
||||
|
||||
interface PayResult {
|
||||
prepay_id?: string;
|
||||
nonceStr?: string;
|
||||
timeStamp?: string;
|
||||
package?: string;
|
||||
paySign?: string;
|
||||
signType?: string;
|
||||
codeUrl?: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number; plan: string; startDate: string; endDate: string; status: string;
|
||||
@@ -29,6 +41,9 @@ export default function MemberPage() {
|
||||
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
||||
const [paymentModal, setPaymentModal] = useState<{
|
||||
open: boolean; orderNo: string; payResult: PayResult; tradeType: 'JSAPI' | 'NATIVE';
|
||||
}>({ open: false, orderNo: '', payResult: {}, tradeType: 'NATIVE' });
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
@@ -51,22 +66,43 @@ export default function MemberPage() {
|
||||
async function handleSubscribe(planType: string) {
|
||||
setPayLoading(planType);
|
||||
try {
|
||||
const inWeChat = isWeChatBrowser();
|
||||
const openid = getOpenidFromUrl();
|
||||
const useJsapi = inWeChat && openid && isMiniProgram();
|
||||
const tradeType = useJsapi ? 'JSAPI' : 'NATIVE';
|
||||
|
||||
const body: Record<string, any> = {
|
||||
amount: planType === 'MONTHLY' ? 29.9 : 199,
|
||||
planType, payChannel: 'wxpay', tradeType,
|
||||
};
|
||||
if (useJsapi && openid) body.openid = openid;
|
||||
|
||||
const res = await apiFetch('/orders/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
amount: planType === 'MONTHLY' ? 29.9 : 199,
|
||||
planType, payChannel: 'wxpay',
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.order && data.payResult) {
|
||||
alert('订单创建成功,请扫码支付(模拟模式)');
|
||||
loadData();
|
||||
// Mock mode → auto-completed by backend
|
||||
if (data.payResult.codeUrl === 'mock://pay') {
|
||||
loadData();
|
||||
} else {
|
||||
setPaymentModal({
|
||||
open: true, orderNo: data.order.orderNo,
|
||||
payResult: data.payResult, tradeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
setPayLoading(null);
|
||||
}
|
||||
|
||||
function handlePaymentPaid() {
|
||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
||||
loadData();
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-2" />
|
||||
@@ -161,6 +197,14 @@ export default function MemberPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PaymentModal
|
||||
open={paymentModal.open}
|
||||
orderNo={paymentModal.orderNo}
|
||||
payResult={paymentModal.payResult}
|
||||
tradeType={paymentModal.tradeType}
|
||||
onPaid={handlePaymentPaid}
|
||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { MessageSquare, Heart } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Prompt {
|
||||
id: number; title: string; description: string; content: string;
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useAuth } from '@/lib/auth-context';
|
||||
import { apiFetch, getToken } from '@/lib/auth';
|
||||
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
|
||||
import { ModelSelector } from '@/components/ui/model-selector';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
|
||||
@@ -9,8 +9,7 @@ import { DEFAULT_MODEL } from '@/lib/models';
|
||||
import { ModelSelector } from '@/components/ui/model-selector';
|
||||
import { useT } from '@/i18n';
|
||||
import { CodeBlock } from '@/components/ui/code-block';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Message {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -22,7 +21,7 @@ interface SessionItem {
|
||||
conversationId: string;
|
||||
model: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
@@ -365,7 +364,7 @@ function SandboxPage() {
|
||||
<div className="text-xs font-medium text-foreground truncate">{s.title}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-[10px] text-muted-foreground">{formatTime(s.updatedAt)} · {s.model}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{formatTime(s.createdAt)} · {s.model}</span>
|
||||
<button onClick={e => { e.stopPropagation(); deleteSession(s.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-[10px] text-red-500 hover:text-red-700">
|
||||
{t.common.delete}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Suspense, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface SharedMessage {
|
||||
role: string;
|
||||
|
||||
@@ -8,8 +8,7 @@ import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SearchIcon, FileText, BookOpen, Wrench, MessageSquare } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface SearchResult {
|
||||
id: number; _type: 'course' | 'prompt' | 'tool' | 'content';
|
||||
|
||||
@@ -5,8 +5,7 @@ import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface SkillTask { label: string; prompt: string }
|
||||
interface Skill {
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Skill {
|
||||
id: string; name: string; description: string; icon: string;
|
||||
|
||||
@@ -5,8 +5,7 @@ import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Wrench, ExternalLink, Star } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Tool {
|
||||
id: number; name: string; description: string; url: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/users`, {
|
||||
const res = await fetch(`${API_BASE}/users`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface UserProfile {
|
||||
id: number; nickname: string; avatar?: string; bio?: string;
|
||||
@@ -39,10 +40,9 @@ export default function UserProfilePage() {
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const [profileRes, postsRes] = await Promise.all([
|
||||
fetch(`${base}/api/v1/community/users/${userId}/profile`),
|
||||
fetch(`${base}/api/v1/community/posts?userId=${userId}`),
|
||||
const [profileRes, postsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/community/users/${userId}/profile`),
|
||||
fetch(`${API_BASE}/community/posts?userId=${userId}`),
|
||||
]);
|
||||
if (profileRes.ok) setProfile(await profileRes.json());
|
||||
if (postsRes.ok) {
|
||||
@@ -52,7 +52,7 @@ export default function UserProfilePage() {
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
const followRes = await fetch(`${base}/api/v1/community/users/${userId}/follow`, {
|
||||
const followRes = await fetch(`${API_BASE}/community/users/${userId}/follow`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (followRes.ok) {
|
||||
@@ -67,8 +67,7 @@ export default function UserProfilePage() {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/community/users/${userId}/follow`, {
|
||||
const res = await fetch(`${API_BASE}/community/users/${userId}/follow`, {
|
||||
method: isFollowing ? 'DELETE' : 'POST',
|
||||
headers: apiHeaders(),
|
||||
});
|
||||
@@ -84,8 +83,7 @@ export default function UserProfilePage() {
|
||||
|
||||
async function loadFollowers() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/community/users/${userId}/followers`);
|
||||
const res = await fetch(`${API_BASE}/community/users/${userId}/followers`);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
setFollowers(d.items || []);
|
||||
@@ -95,8 +93,7 @@ export default function UserProfilePage() {
|
||||
|
||||
async function loadFollowing() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const res = await fetch(`${base}/api/v1/community/users/${userId}/following`);
|
||||
const res = await fetch(`${API_BASE}/community/users/${userId}/following`);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
setFollowing(d.items || []);
|
||||
|
||||
Reference in New Issue
Block a user