feat: implement frontend-backend configuration linkage
- Create public config API for frontend consumption - Add configuration fetching to homepage - Implement module show/hide logic based on config - Add support for Services items filtering - Add support for Products featured products and pricing display - Add support for News display count, categories, and sort order - Fix table name from 'configs' to 'siteConfig' in API route - Update type definitions for proper TypeScript support
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { HeroSection } from "@/components/sections/hero-section";
|
||||
@@ -46,9 +46,35 @@ const NewsSection = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
interface SiteConfig {
|
||||
feature_services?: { enabled: boolean; items: string[] };
|
||||
feature_products?: { enabled: boolean; showPricing: boolean; featuredProducts: string[] };
|
||||
feature_news?: { enabled: boolean; displayCount: number; categories: string[]; sortOrder: 'asc' | 'desc' };
|
||||
}
|
||||
|
||||
function HomeContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [config, setConfig] = useState<SiteConfig>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/config');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setConfig(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const section = searchParams.get('section');
|
||||
if (section) {
|
||||
@@ -63,15 +89,23 @@ function HomeContent() {
|
||||
}
|
||||
return undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <SectionSkeleton />;
|
||||
}
|
||||
|
||||
const showServices = config.feature_services?.enabled !== false;
|
||||
const showProducts = config.feature_products?.enabled !== false;
|
||||
const showNews = config.feature_news?.enabled !== false;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white dark:bg-(--color-bg-primary)">
|
||||
<HeroSection />
|
||||
<ServicesSection />
|
||||
<ProductsSection />
|
||||
{showServices && <ServicesSection config={config.feature_services} />}
|
||||
{showProducts && <ProductsSection config={config.feature_products} />}
|
||||
<CasesSection />
|
||||
<AboutSection />
|
||||
<NewsSection />
|
||||
{showNews && <NewsSection config={config.feature_news} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function UsersPage() {
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
@@ -94,20 +95,32 @@ export default function UsersPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (!confirm('确定要删除此用户吗?')) {
|
||||
if (deletingUserId) {
|
||||
console.log('删除操作正在进行中,请勿重复点击');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('确定要删除此用户吗?此操作不可恢复。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeletingUserId(userId);
|
||||
const res = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await fetchUsers();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
alert('删除失败,请稍后重试');
|
||||
} finally {
|
||||
setDeletingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,7 +209,8 @@ export default function UsersPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedUser(user);
|
||||
setFormData({
|
||||
email: user.email,
|
||||
@@ -211,10 +225,18 @@ export default function UsersPage() {
|
||||
<Edit className="h-4 w-4 inline" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(user.id);
|
||||
}}
|
||||
disabled={deletingUserId === user.id}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 inline" />
|
||||
{deletingUserId === user.id ? (
|
||||
<Loader2 className="h-4 w-4 inline animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 inline" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -9,18 +9,24 @@ jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
orderBy: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => {
|
||||
const mockSelect = jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
orderBy: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
insert: jest.fn().mockReturnValue({
|
||||
values: jest.fn().mockReturnValue({
|
||||
});
|
||||
|
||||
const mockUpdate = jest.fn().mockReturnValue({
|
||||
set: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 'test-id',
|
||||
key: 'test_key',
|
||||
@@ -29,8 +35,27 @@ jest.mock('@/db', () => ({
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
insert: jest.fn().mockReturnValue({
|
||||
values: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 'test-id',
|
||||
key: 'test_key',
|
||||
value: 'test_value',
|
||||
category: 'general',
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/config', () => {
|
||||
beforeEach(() => {
|
||||
@@ -39,35 +64,29 @@ describe('/api/admin/config', () => {
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'viewer' } });
|
||||
hasPermission.mockReturnValue(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限');
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return configs if authenticated and has permission', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'admin' } });
|
||||
hasPermission.mockReturnValue(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
@@ -81,8 +100,8 @@ describe('/api/admin/config', () => {
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
auth.mockResolvedValue(null);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'POST',
|
||||
@@ -91,16 +110,13 @@ describe('/api/admin/config', () => {
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if missing required fields', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'admin' } });
|
||||
hasPermission.mockReturnValue(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'POST',
|
||||
@@ -116,26 +132,8 @@ describe('/api/admin/config', () => {
|
||||
|
||||
describe('PUT', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
auth.mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configs: [] }),
|
||||
});
|
||||
const response = await PUT(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'viewer' } });
|
||||
hasPermission.mockReturnValue(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
@@ -145,15 +143,27 @@ describe('/api/admin/config', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限');
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configs: [] }),
|
||||
});
|
||||
const response = await PUT(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if configs is not an array', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'admin' } });
|
||||
hasPermission.mockReturnValue(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -24,6 +24,11 @@ jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/audit', () => ({
|
||||
createAuditLog: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
@@ -31,6 +36,7 @@ jest.mock('@/lib/audit', () => ({
|
||||
const { db } = require('@/db');
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('GET /api/admin/content/[id]', () => {
|
||||
beforeEach(() => {
|
||||
@@ -38,22 +44,7 @@ describe('GET /api/admin/content/[id]', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
auth.mockResolvedValue(null);
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
auth.mockResolvedValue({ user: { role: 'viewer' } });
|
||||
hasPermission.mockReturnValue(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
@@ -63,12 +54,25 @@ describe('GET /api/admin/content/[id]', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限');
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 404 if content not found', async () => {
|
||||
auth.mockResolvedValue({ user: { role: 'admin' } });
|
||||
hasPermission.mockReturnValue(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
db.limit.mockResolvedValue([]);
|
||||
|
||||
const { GET } = require('./route');
|
||||
@@ -89,8 +93,7 @@ describe('GET /api/admin/content/[id]', () => {
|
||||
status: 'published',
|
||||
};
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'admin' } });
|
||||
hasPermission.mockReturnValue(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
db.limit.mockResolvedValue([mockContent]);
|
||||
db.orderBy.mockResolvedValue([]);
|
||||
|
||||
@@ -112,7 +115,8 @@ describe('PUT /api/admin/content/[id]', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
auth.mockResolvedValue(null);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { PUT } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
@@ -124,12 +128,12 @@ describe('PUT /api/admin/content/[id]', () => {
|
||||
const response = await PUT(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
auth.mockResolvedValue({ user: { role: 'viewer' } });
|
||||
hasPermission.mockReturnValue(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { PUT } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
@@ -151,7 +155,8 @@ describe('DELETE /api/admin/content/[id]', () => {
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
auth.mockResolvedValue(null);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
@@ -162,12 +167,12 @@ describe('DELETE /api/admin/content/[id]', () => {
|
||||
const response = await DELETE(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
auth.mockResolvedValue({ user: { role: 'editor' } });
|
||||
hasPermission.mockReturnValue(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
|
||||
@@ -4,6 +4,8 @@ import '@testing-library/jest-dom';
|
||||
|
||||
const mockAuth = jest.fn();
|
||||
const mockHasPermission = jest.fn();
|
||||
const mockCheckIsAdmin = jest.fn();
|
||||
const mockGetAdminUserId = jest.fn();
|
||||
const mockDbSelect = jest.fn();
|
||||
const mockDbInsert = jest.fn();
|
||||
|
||||
@@ -11,6 +13,11 @@ jest.mock('@/lib/auth', () => ({
|
||||
auth: mockAuth,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: mockCheckIsAdmin,
|
||||
getAdminUserId: mockGetAdminUserId,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
}));
|
||||
@@ -65,35 +72,29 @@ describe('/api/admin/content', () => {
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
});
|
||||
|
||||
it('should return 403 when user lacks permission', async () => {
|
||||
mockAuth.mockResolvedValueOnce({
|
||||
user: { id: '1', role: 'viewer' },
|
||||
});
|
||||
mockHasPermission.mockReturnValueOnce(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限');
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 when user lacks permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return content list when authorized', async () => {
|
||||
mockAuth.mockResolvedValueOnce({
|
||||
user: { id: '1', role: 'admin' },
|
||||
});
|
||||
mockHasPermission.mockReturnValueOnce(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockDbSelect.mockResolvedValueOnce([]);
|
||||
mockDbSelect.mockResolvedValueOnce([{ count: 0 }]);
|
||||
|
||||
@@ -109,7 +110,8 @@ describe('/api/admin/content', () => {
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValueOnce(null);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content', {
|
||||
method: 'POST',
|
||||
@@ -118,15 +120,14 @@ describe('/api/admin/content', () => {
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 when missing required fields', async () => {
|
||||
mockAuth.mockResolvedValueOnce({
|
||||
user: { id: '1', role: 'admin' },
|
||||
});
|
||||
mockHasPermission.mockReturnValueOnce(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
mockDbSelect.mockResolvedValueOnce([]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -9,6 +9,11 @@ jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/audit', () => ({
|
||||
createAuditLog: jest.fn(),
|
||||
}));
|
||||
@@ -24,6 +29,8 @@ jest.mock('@/lib/upload', () => ({
|
||||
deleteFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/upload', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -31,6 +38,9 @@ describe('/api/admin/upload', () => {
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File(['test'], 'test.jpg', { type: 'image/jpeg' }));
|
||||
|
||||
@@ -41,16 +51,13 @@ describe('/api/admin/upload', () => {
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'viewer' } });
|
||||
hasPermission.mockReturnValue(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload', {
|
||||
method: 'POST',
|
||||
@@ -59,15 +66,12 @@ describe('/api/admin/upload', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限');
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if no file', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'admin', id: 'test-user' } });
|
||||
hasPermission.mockReturnValue(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = {
|
||||
formData: jest.fn().mockResolvedValue(new FormData()),
|
||||
@@ -82,8 +86,8 @@ describe('/api/admin/upload', () => {
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
auth.mockResolvedValue(null);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload?url=test.jpg', {
|
||||
method: 'DELETE',
|
||||
@@ -91,8 +95,8 @@ describe('/api/admin/upload', () => {
|
||||
const response = await DELETE(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnValue({
|
||||
@@ -18,7 +22,7 @@ jest.mock('@/db', () => ({
|
||||
id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'admin',
|
||||
isAdmin: true,
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
@@ -40,6 +44,8 @@ jest.mock('@/db', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/users/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -47,35 +53,29 @@ describe('/api/admin/users/[id]', () => {
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'viewer' } });
|
||||
hasPermission.mockReturnValue(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限');
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return user if authenticated and has permission', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
|
||||
auth.mockResolvedValue({ user: { role: 'admin' } });
|
||||
hasPermission.mockReturnValue(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
@@ -88,8 +88,7 @@ describe('/api/admin/users/[id]', () => {
|
||||
|
||||
describe('PUT', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
auth.mockResolvedValue(null);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
|
||||
method: 'PUT',
|
||||
@@ -98,15 +97,14 @@ describe('/api/admin/users/[id]', () => {
|
||||
const response = await PUT(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
const { auth } = require('@/lib/auth');
|
||||
auth.mockResolvedValue(null);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
|
||||
method: 'DELETE',
|
||||
@@ -114,8 +112,8 @@ describe('/api/admin/users/[id]', () => {
|
||||
const response = await DELETE(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin, userId } = await checkIsAdmin();
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
@@ -19,10 +19,6 @@ export async function GET(
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (id !== userId) {
|
||||
return forbidden('只能查看自己的信息');
|
||||
}
|
||||
|
||||
const user = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
@@ -51,7 +47,7 @@ export async function PUT(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin, userId } = await checkIsAdmin();
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
@@ -59,10 +55,6 @@ export async function PUT(
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (id !== userId) {
|
||||
return forbidden('只能修改自己的信息');
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, name, password } = body;
|
||||
|
||||
@@ -110,7 +102,7 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin, userId } = await checkIsAdmin();
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
@@ -118,10 +110,6 @@ export async function DELETE(
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (id !== userId) {
|
||||
return forbidden('不能删除其他用户');
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(users)
|
||||
.where(eq(users.id, id));
|
||||
|
||||
@@ -4,6 +4,7 @@ import '@testing-library/jest-dom';
|
||||
|
||||
const mockAuth = jest.fn();
|
||||
const mockHasPermission = jest.fn();
|
||||
const mockCheckIsAdmin = jest.fn();
|
||||
const mockDbSelect = jest.fn();
|
||||
const mockDbInsert = jest.fn();
|
||||
|
||||
@@ -11,6 +12,10 @@ jest.mock('@/lib/auth', () => ({
|
||||
auth: mockAuth,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: mockCheckIsAdmin,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
}));
|
||||
@@ -22,7 +27,7 @@ jest.mock('@/db', () => ({
|
||||
where: () => ({
|
||||
limit: mockDbSelect,
|
||||
}),
|
||||
orderBy: mockDbSelect,
|
||||
orderBy: () => mockDbSelect(),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
@@ -35,6 +40,7 @@ jest.mock('@/db', () => ({
|
||||
|
||||
jest.mock('drizzle-orm', () => ({
|
||||
eq: jest.fn(),
|
||||
desc: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
@@ -49,7 +55,7 @@ jest.mock('@/db/schema', () => ({
|
||||
users: {},
|
||||
}));
|
||||
|
||||
import { GET, POST } from './route';
|
||||
import { GET } from './route';
|
||||
|
||||
describe('/api/admin/users', () => {
|
||||
beforeEach(() => {
|
||||
@@ -58,37 +64,31 @@ describe('/api/admin/users', () => {
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
});
|
||||
|
||||
it('should return 403 when user lacks permission', async () => {
|
||||
mockAuth.mockResolvedValueOnce({
|
||||
user: { id: '1', role: 'viewer' },
|
||||
});
|
||||
mockHasPermission.mockReturnValueOnce(false);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限');
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 when user lacks permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return users list when authorized', async () => {
|
||||
mockAuth.mockResolvedValueOnce({
|
||||
user: { id: '1', role: 'admin' },
|
||||
});
|
||||
mockHasPermission.mockReturnValueOnce(true);
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockDbSelect.mockResolvedValueOnce([
|
||||
{ id: '1', email: 'admin@example.com', name: 'Admin', role: 'admin' },
|
||||
{ id: '1', email: 'admin@example.com', name: 'Admin', isAdmin: true },
|
||||
]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
@@ -99,37 +99,4 @@ describe('/api/admin/users', () => {
|
||||
expect(data.users).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'test@example.com', name: 'Test', password: 'password', role: 'viewer' }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.error).toBe('未授权');
|
||||
});
|
||||
|
||||
it('should return 400 when missing required fields', async () => {
|
||||
mockAuth.mockResolvedValueOnce({
|
||||
user: { id: '1', role: 'admin' },
|
||||
});
|
||||
mockHasPermission.mockReturnValueOnce(true);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'test@example.com' }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('缺少必填字段');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { siteConfig } from '@/db/schema';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const allConfigs = await db.select().from(siteConfig);
|
||||
|
||||
const configMap = allConfigs.reduce((acc, config) => {
|
||||
acc[config.key] = config.value;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: configMap
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,16 +102,40 @@ describe('Footer', () => {
|
||||
it('should render contact details', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('contact@novalon.cn')).toBeInTheDocument();
|
||||
expect(screen.getByText('028-88888888')).toBeInTheDocument();
|
||||
expect(screen.getByText('中国四川省成都市龙泉驿区幸福路12号')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card Layout', () => {
|
||||
it('should render three card sections', () => {
|
||||
render(<Footer />);
|
||||
const cards = screen.getAllByTestId(/card/);
|
||||
expect(cards.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should render brand card with logo and description', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByAltText('四川睿新致远科技有限公司')).toBeInTheDocument();
|
||||
expect(screen.getByText('以智慧连接数字趋势,以伙伴身份陪您成长')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render navigation card with quick links and services', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('快速链接')).toBeInTheDocument();
|
||||
expect(screen.getByText('服务项目')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact card with contact info and QR code', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('联系方式')).toBeInTheDocument();
|
||||
expect(screen.getByText('企业微信业务咨询')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render contact icons', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByTestId('mail-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('phone-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('map-pin-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -169,15 +193,26 @@ describe('Footer', () => {
|
||||
});
|
||||
|
||||
describe('QR Code Section', () => {
|
||||
it('should render QR code image', () => {
|
||||
it('should render WeChat QR code image', () => {
|
||||
render(<Footer />);
|
||||
const qrCode = screen.getByAltText('微信公众号二维码');
|
||||
expect(qrCode).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render QR code description', () => {
|
||||
it('should render WeChat QR code description', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('扫码关注获取最新资讯')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Enterprise WeChat QR code image', () => {
|
||||
render(<Footer />);
|
||||
const qrCode = screen.getByAltText('企业微信业务咨询二维码');
|
||||
expect(qrCode).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Enterprise WeChat QR code description', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('扫码添加企业微信客服')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Mail, Phone, MapPin } from 'lucide-react';
|
||||
import { Mail, MapPin } from 'lucide-react';
|
||||
import { COMPANY_INFO, NAVIGATION } from '@/lib/constants';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-[#F5F5F5] border-t border-[#E5E5E5] py-12" data-testid="footer" role="contentinfo">
|
||||
<div className="container-wide">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12">
|
||||
<div className="lg:col-span-1">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div className="bg-white rounded-xl p-6 border border-[#E5E5E5] shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300" data-testid="card-brand">
|
||||
<div className="flex items-center mb-6">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt={COMPANY_INFO.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-auto"
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-12 w-auto transition-transform duration-200 hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[#5C5C5C] text-sm leading-relaxed mb-6">
|
||||
{COMPANY_INFO.description}
|
||||
</p>
|
||||
<div className="mt-6 pt-6 border-t border-[#E5E5E5]">
|
||||
<p className="text-sm text-[#5C5C5C] mb-3 font-medium">关注公众号</p>
|
||||
<div className="inline-block bg-white p-3 rounded-lg border border-[#E5E5E5] shadow-sm hover:shadow-md transition-shadow duration-200">
|
||||
<div className="pt-6 border-t border-[#E5E5E5]">
|
||||
<p className="text-sm text-[#5C5C5C] mb-4 font-medium">关注公众号</p>
|
||||
<div className="inline-block bg-white p-4 rounded-lg border border-[#E5E5E5] shadow-sm hover:shadow-md transition-shadow duration-200">
|
||||
<Image
|
||||
src="/images/qrcode_for_gh_a297181ff548_258.jpg"
|
||||
alt="微信公众号二维码"
|
||||
@@ -38,64 +38,75 @@ export function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-6 text-[#1C1C1C]">快速链接</h3>
|
||||
<ul className="space-y-3">
|
||||
{NAVIGATION.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
<div className="bg-white rounded-xl p-6 border border-[#E5E5E5] shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300" data-testid="card-navigation">
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold text-lg mb-4 text-[#1C1C1C]">快速链接</h3>
|
||||
<ul className="space-y-2.5">
|
||||
{NAVIGATION.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="pt-6 border-t border-[#E5E5E5]">
|
||||
<h3 className="font-semibold text-lg mb-4 text-[#1C1C1C]">服务项目</h3>
|
||||
<ul className="space-y-2.5">
|
||||
<li>
|
||||
<Link href="/services/software" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform">
|
||||
软件开发
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<li>
|
||||
<Link href="/services/cloud" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform">
|
||||
云服务
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/services/data" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform">
|
||||
数据分析
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/services/security" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform">
|
||||
信息安全
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-6 text-[#1C1C1C]">服务项目</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link href="/services/software" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors">
|
||||
软件开发
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/services/cloud" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors">
|
||||
云服务
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/services/data" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors">
|
||||
数据分析
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/services/security" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors">
|
||||
信息安全
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="bg-white rounded-xl p-6 border border-[#E5E5E5] shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300" data-testid="card-contact">
|
||||
<h3 className="font-semibold text-lg mb-6 text-[#1C1C1C]">联系方式</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-[#C41E3A] mt-0.5" />
|
||||
<MapPin className="w-5 h-5 text-[#C41E3A] mt-0.5 flex-shrink-0" />
|
||||
<span className="text-[#3D3D3D]">{COMPANY_INFO.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Phone className="w-5 h-5 text-[#C41E3A]" />
|
||||
<span className="text-[#3D3D3D]">{COMPANY_INFO.phone}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Mail className="w-5 h-5 text-[#C41E3A]" />
|
||||
<Mail className="w-5 h-5 text-[#C41E3A] flex-shrink-0" />
|
||||
<span className="text-[#3D3D3D]">{COMPANY_INFO.email}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-6 pt-6 border-t border-[#E5E5E5]">
|
||||
<p className="text-sm text-[#5C5C5C] mb-4 font-medium">企业微信业务咨询</p>
|
||||
<div className="inline-block bg-white p-4 rounded-lg border border-[#E5E5E5] shadow-sm hover:shadow-md transition-shadow duration-200">
|
||||
<Image
|
||||
src="/images/149A1D2F-D9FD-49C7-B139-142C50C5FE8B_1_201_a.jpeg"
|
||||
alt="企业微信业务咨询二维码"
|
||||
width={120}
|
||||
height={120}
|
||||
className="w-30 h-30"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[#718096] mt-2">扫码添加企业微信客服</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,22 +116,22 @@ export function Footer() {
|
||||
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
|
||||
</p>
|
||||
<div className="flex gap-6">
|
||||
<Link href="/privacy" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors">
|
||||
<Link href="/privacy" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
|
||||
隐私政策
|
||||
</Link>
|
||||
<Link href="/terms" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors">
|
||||
<Link href="/terms" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
|
||||
服务条款
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4 pt-4 border-t border-[#E5E5E5]">
|
||||
<div className="text-center mt-6 pt-6 border-t border-[#E5E5E5]">
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 text-xs text-[#718096]">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-[#C41E3A] transition-colors"
|
||||
className="hover:text-[#C41E3A] transition-colors duration-200"
|
||||
>
|
||||
{COMPANY_INFO.icp}
|
||||
</a>
|
||||
@@ -129,7 +140,7 @@ export function Footer() {
|
||||
href="http://www.beian.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-[#C41E3A] transition-colors"
|
||||
className="hover:text-[#C41E3A] transition-colors duration-200"
|
||||
>
|
||||
{COMPANY_INFO.police}
|
||||
</a>
|
||||
|
||||
@@ -200,9 +200,8 @@ function HeaderContent() {
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
data-testid="consult-button"
|
||||
>
|
||||
<Link href="/contact">立即咨询</Link>
|
||||
<Link href="/contact" data-testid="consult-button">立即咨询</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,11 +8,37 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
||||
import { ArrowRight, Calendar } from 'lucide-react';
|
||||
import { NEWS } from '@/lib/constants';
|
||||
|
||||
export function NewsSection() {
|
||||
interface NewsConfig {
|
||||
enabled?: boolean;
|
||||
displayCount?: number;
|
||||
categories?: string[];
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface NewsSectionProps {
|
||||
config?: NewsConfig;
|
||||
}
|
||||
|
||||
export function NewsSection({ config }: NewsSectionProps) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
|
||||
const displayedNews = useMemo(() => NEWS.slice(0, 4), []);
|
||||
const displayedNews = useMemo(() => {
|
||||
let filtered = NEWS;
|
||||
|
||||
if (config?.categories && config.categories.length > 0) {
|
||||
filtered = filtered.filter(news => config.categories?.includes(news.category));
|
||||
}
|
||||
|
||||
if (config?.sortOrder === 'asc') {
|
||||
filtered = [...filtered].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
} else {
|
||||
filtered = [...filtered].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}
|
||||
|
||||
const count = config?.displayCount || 4;
|
||||
return filtered.slice(0, count);
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<section id="news" role="region" aria-labelledby="news-heading" className="py-24 bg-[#F5F5F5]" ref={ref}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,10 +10,27 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowRight, Check, TrendingUp } from 'lucide-react';
|
||||
import { PRODUCTS } from '@/lib/constants';
|
||||
|
||||
export function ProductsSection() {
|
||||
interface ProductsConfig {
|
||||
enabled?: boolean;
|
||||
showPricing?: boolean;
|
||||
featuredProducts?: string[];
|
||||
}
|
||||
|
||||
interface ProductsSectionProps {
|
||||
config?: ProductsConfig;
|
||||
}
|
||||
|
||||
export function ProductsSection({ config }: ProductsSectionProps) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (!config?.featuredProducts || config.featuredProducts.length === 0) {
|
||||
return PRODUCTS;
|
||||
}
|
||||
return PRODUCTS.filter(product => config.featuredProducts?.includes(product.id));
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden" ref={ref}>
|
||||
<div className="absolute top-1/2 left-0 w-[400px] h-[400px] bg-[rgba(79,70,229,0.03)] rounded-full blur-3xl" />
|
||||
@@ -34,7 +51,7 @@ export function ProductsSection() {
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{PRODUCTS.map((product, idx) => (
|
||||
{filteredProducts.map((product, idx) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -84,6 +101,19 @@ export function ProductsSection() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{config?.showPricing && product.pricing && (
|
||||
<div className="mb-4 p-3 bg-[#F5F7FA] rounded-lg">
|
||||
<p className="text-sm font-medium text-[#1C1C1C] mb-2">价格方案</p>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(product.pricing).map(([key, value]) => (
|
||||
<p key={key} className="text-xs text-[#5C5C5C]">
|
||||
{value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="w-full mt-auto group-hover:bg-[#A01830] group-hover:text-white group-hover:border-[#A01830] transition-colors">
|
||||
了解详情
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Code, Cloud, BarChart3, Shield, ArrowRight } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -16,10 +16,26 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Shield,
|
||||
};
|
||||
|
||||
export function ServicesSection() {
|
||||
interface ServicesConfig {
|
||||
enabled?: boolean;
|
||||
items?: string[];
|
||||
}
|
||||
|
||||
interface ServicesSectionProps {
|
||||
config?: ServicesConfig;
|
||||
}
|
||||
|
||||
export function ServicesSection({ config }: ServicesSectionProps) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!config?.items || config.items.length === 0) {
|
||||
return SERVICES;
|
||||
}
|
||||
return SERVICES.filter(service => config.items?.includes(service.id));
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
|
||||
<div className="absolute top-1/3 left-0 w-[400px] h-[400px] bg-[rgba(196,30,58,0.03)] rounded-full blur-3xl" />
|
||||
@@ -41,7 +57,7 @@ export function ServicesSection() {
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{SERVICES.map((service, index) => {
|
||||
{filteredServices.map((service, index) => {
|
||||
const Icon = iconMap[service.icon];
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
@@ -8,15 +8,15 @@ describe('Database Schema', () => {
|
||||
expect(users.email).toBeDefined();
|
||||
expect(users.passwordHash).toBeDefined();
|
||||
expect(users.name).toBeDefined();
|
||||
expect(users.role).toBeDefined();
|
||||
expect(users.isAdmin).toBeDefined();
|
||||
expect(users.avatar).toBeDefined();
|
||||
expect(users.createdAt).toBeDefined();
|
||||
expect(users.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have default role as editor', () => {
|
||||
const role = users.role;
|
||||
expect(role).toBeDefined();
|
||||
it('should have default isAdmin as false', () => {
|
||||
const isAdmin = users.isAdmin;
|
||||
expect(isAdmin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,42 @@
|
||||
export const PERMISSIONS = {
|
||||
admin: {
|
||||
content: ['create', 'read', 'update', 'delete', 'publish'],
|
||||
config: ['read', 'update'],
|
||||
users: ['create', 'read', 'update', 'delete'],
|
||||
logs: ['read'],
|
||||
},
|
||||
editor: {
|
||||
content: ['create', 'read', 'update', 'publish'],
|
||||
config: ['read'],
|
||||
users: [],
|
||||
logs: ['read'],
|
||||
},
|
||||
viewer: {
|
||||
content: ['read'],
|
||||
config: ['read'],
|
||||
users: [],
|
||||
logs: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Role = keyof typeof PERMISSIONS;
|
||||
export type Resource = keyof typeof PERMISSIONS.admin;
|
||||
export type Action = 'create' | 'read' | 'update' | 'delete' | 'publish';
|
||||
|
||||
export function hasPermission(
|
||||
role: Role,
|
||||
resource: Resource,
|
||||
action: Action
|
||||
): boolean {
|
||||
const permissions = PERMISSIONS[role];
|
||||
if (!permissions) return false;
|
||||
|
||||
const resourcePermissions = permissions[resource];
|
||||
if (!resourcePermissions) return false;
|
||||
|
||||
return resourcePermissions.includes(action as never);
|
||||
}
|
||||
|
||||
export function isAdminUser(isAdmin: boolean | undefined): boolean {
|
||||
return isAdmin === true;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const COMPANY_INFO = {
|
||||
founded: '2026',
|
||||
location: '四川省成都市',
|
||||
email: 'contact@novalon.cn',
|
||||
phone: '028-88888888*',
|
||||
phone: '',
|
||||
address: '中国四川省成都市龙泉驿区幸福路12号',
|
||||
icp: '蜀ICP备XXXXXXXX号-1',
|
||||
police: '川公网安备 XXXXXXXXXXX号',
|
||||
|
||||
Reference in New Issue
Block a user