fix:初始化

This commit is contained in:
Your Name 2026-06-22 14:43:46 +08:00
parent eaf3ce327e
commit 9003d8718a
83 changed files with 13204 additions and 0 deletions

6
.env.production Normal file
View File

@ -0,0 +1,6 @@
# 后端接口地址(后端服务端口由 server/.env 的 PORT 决定,当前为 3000
NEXT_PUBLIC_API_URL=https://web-api.linyikj.com.cn/api
# 图片资源访问地址
NEXT_PUBLIC_UPLOAD_URL=https://web-api.linyikj.com.cn/uploads
# JWT本地存储Key
NEXT_PUBLIC_TOKEN_KEY=admin_token

7
.eslintrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"react/no-unescaped-entities": "off"
}
}

88
.gitea/workflows/main.yml Normal file
View File

@ -0,0 +1,88 @@
name: main
on:
push:
branches: ["main"]
jobs:
build-and-push:
name: Build and push to Aliyun ACR
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: https://gitee.com/zsqai/checkout@v4
- name: Set up Docker Buildx
uses: https://gitee.com/zsqai/setup-buildx-action@v3
- name: Login to Aliyun Container Registry
uses: https://gitee.com/zsqai/login-action@v3
with:
registry: ${{ vars.ALIYUN_REGISTRY }}
username: ${{ vars.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_PASSWORD }}
- name: Build and push Docker image
uses: https://gitee.com/zsqai/build-push-action@v5
with:
context: .
push: true
# 禁用所有缓存,确保每次都是全新构建
no-cache: true
build-args: |
BUILD_VERSION=${{ github.sha }}
BUILD_TIME=${{ github.run_number }}
CACHE_BUST=${{ github.run_id }}
tags: |
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:${{ github.sha }}
deploy:
name: Deploy to server
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Deploy via SSH
uses: https://gitee.com/zsqai/ssh-action@v1.0.3
with:
host: ${{ vars.HOST }}
username: ${{ vars.USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: 22
script: |
# 登录阿里云镜像仓库
docker login --username=${{ vars.ALIYUN_USERNAME }} --password=${{ secrets.ALIYUN_PASSWORD }} ${{ vars.ALIYUN_REGISTRY }}
# 停止并删除旧容器
docker stop ai-personage-web 2>/dev/null || true
docker rm ai-personage-web 2>/dev/null || true
# 删除旧镜像(强制重新拉取)
docker rmi ${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest 2>/dev/null || true
# 强制拉取最新镜像
docker pull ${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
# 运行新容器
docker run -d \
--name ai-personage-web \
--restart always \
-p 8085:3003 \
-e NODE_OPTIONS="--max-old-space-size=4096" \
-e NODE_ENV="production" \
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
# 等待启动
sleep 3
# 查看 BUILD_ID 确认更新
echo "=== Build ID ==="
docker exec -it ai-personage-web cat .next/BUILD_ID 2>/dev/null || echo "Cannot read BUILD_ID"
# 查看日志
echo ""
echo "=== Container Logs ==="
docker logs ai-personage-web --tail 20
# 清理无用镜像
docker image prune -f

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
.next/
.env
.env.local
*.log

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"printWidth": 100,
"tabWidth": 2
}

View File

@ -0,0 +1,24 @@
import type { Metadata } from 'next';
import { publicApi } from '@/lib/services';
export const metadata: Metadata = {
title: '关于我们',
description: '了解企业的发展历程、团队与文化。',
};
export const revalidate = 60;
export default async function AboutPage() {
const config = await publicApi.getSiteConfig().catch(() => null);
return (
<div className="container-page py-12">
<h1 className="text-3xl font-bold text-gray-900">
{config?.aboutTitle || '关于我们'}
</h1>
<div
className="prose-rich mt-6 max-w-3xl"
dangerouslySetInnerHTML={{ __html: config?.aboutContent ?? '' }}
/>
</div>
);
}

View File

@ -0,0 +1,54 @@
import type { Metadata } from 'next';
import { publicApi } from '@/lib/services';
import { ContactForm } from '@/components/front/ContactForm';
export const metadata: Metadata = {
title: '联系我们',
description: '通过电话、邮箱或在线留言与我们取得联系。',
};
export const revalidate = 60;
export default async function ContactPage() {
const config = await publicApi.getSiteConfig().catch(() => null);
return (
<div className="container-page py-12">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-500">
访
</p>
<div className="mt-10 grid gap-10 md:grid-cols-2">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<ul className="mt-4 space-y-4 text-sm text-gray-700">
{config?.tel && (
<li>
<div className="text-gray-400"></div>
<div className="text-base">{config.tel}</div>
</li>
)}
{config?.email && (
<li>
<div className="text-gray-400"></div>
<div className="text-base">{config.email}</div>
</li>
)}
{config?.address && (
<li>
<div className="text-gray-400"></div>
<div className="text-base">{config.address}</div>
</li>
)}
</ul>
</div>
<div className="rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900">线</h2>
<div className="mt-4">
<ContactForm />
</div>
</div>
</div>
</div>
);
}

29
app/(front)/layout.tsx Normal file
View File

@ -0,0 +1,29 @@
import { ReactNode } from 'react';
import { Header } from '@/components/front/Header';
import { Footer } from '@/components/front/Footer';
import { publicApi } from '@/lib/services';
export const revalidate = 60; // ISR60 秒重新生成
async function getConfig() {
try {
return await publicApi.getSiteConfig();
} catch {
return null;
}
}
export default async function FrontLayout({
children,
}: {
children: ReactNode;
}) {
const config = await getConfig();
return (
<div className="flex min-h-screen flex-col">
<Header siteName={config?.siteName ?? '企业官方网站'} />
<main className="flex-1">{children}</main>
<Footer config={config} />
</div>
);
}

View File

@ -0,0 +1,34 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { publicApi } from '@/lib/services';
import { ManualLayout } from '@/components/front/ManualLayout';
export const revalidate = 60;
interface PageProps {
params: { id: string };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
try {
const doc = await publicApi.getManualDetail(Number(params.id));
return { title: doc.title, description: doc.title };
} catch {
return { title: '使用手册' };
}
}
export default async function ManualDetailPage({ params }: PageProps) {
const id = Number(params.id);
if (!Number.isFinite(id)) notFound();
// 并行拉取树形菜单 + 当前文档详情
const [tree, doc] = await Promise.all([
publicApi.getManualTree(),
publicApi.getManualDetail(id).catch(() => null),
]);
if (!doc) notFound();
return <ManualLayout tree={tree} doc={doc} />;
}

View File

@ -0,0 +1,36 @@
import type { Metadata } from 'next';
import { publicApi } from '@/lib/services';
import { ManualLayout } from '@/components/front/ManualLayout';
import type { Manual, ManualTreeNode } from '@/lib/types';
export const revalidate = 60;
export const metadata: Metadata = {
title: '使用手册',
description: '产品使用手册与文档',
};
/** 扁平化树为「仅文档」节点,用于查找第一篇可显示的文档 */
function flattenDocIds(nodes: ManualTreeNode[]): number[] {
const out: number[] = [];
const walk = (list: ManualTreeNode[]) => {
for (const n of list) {
if (n.type === 1) out.push(n.id);
if (n.children.length > 0) walk(n.children);
}
};
walk(nodes);
return out;
}
export default async function ManualIndexPage() {
const tree = await publicApi.getManualTree();
const docIds = flattenDocIds(tree);
// 优先展示第一篇文档的内容;树中无文档节点时显示空态
const firstDoc: Manual | null = docIds[0]
? await publicApi.getManualDetail(docIds[0]).catch(() => null)
: null;
return <ManualLayout tree={tree} doc={firstDoc} />;
}

View File

@ -0,0 +1,72 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { publicApi } from '@/lib/services';
import { formatDate, resolveUploadUrl } from '@/lib/utils';
import { ContentView } from '@/components/front/ContentView';
export const revalidate = 60;
interface PageProps {
params: { id: string };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
try {
const n = await publicApi.getNewsDetail(Number(params.id));
return { title: n.title, description: n.intro ?? undefined };
} catch {
return { title: '新闻详情' };
}
}
export default async function NewsDetailPage({ params }: PageProps) {
let news;
try {
news = await publicApi.getNewsDetail(Number(params.id));
} catch {
notFound();
}
return (
<article className="container-page py-12">
<nav className="mb-4 text-sm text-gray-500">
<Link href="/news" className="hover:text-brand-600">
</Link>
<span className="mx-2">/</span>
<span>{news.title}</span>
</nav>
<header className="border-b border-gray-100 pb-4">
<h1 className="text-2xl font-bold text-gray-900 sm:text-3xl">
{news.title}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
{news.category?.name && (
<span className="rounded bg-brand-50 px-2 py-0.5 text-brand-700">
{news.category.name}
</span>
)}
<span>{formatDate(news.createdAt, 'YYYY-MM-DD HH:mm')}</span>
{news.isTop === 1 && (
<span className="text-red-600"></span>
)}
</div>
</header>
{news.cover && (
<div className="my-6 overflow-hidden rounded-lg">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveUploadUrl(news.cover)}
alt={news.title}
className="max-h-96 w-full object-cover"
/>
</div>
)}
<ContentView content={news.content} className="mt-4 max-w-4xl" />
</article>
);
}

98
app/(front)/news/page.tsx Normal file
View File

@ -0,0 +1,98 @@
import type { Metadata } from 'next';
import { publicApi } from '@/lib/services';
import { NewsCard } from '@/components/front/NewsCard';
export const metadata: Metadata = {
title: '新闻资讯',
description: '公司动态与行业新闻。',
};
export const revalidate = 60;
interface PageProps {
searchParams: { categoryId?: string; keyword?: string; page?: string };
}
export default async function NewsPage({ searchParams }: PageProps) {
const page = Number(searchParams.page ?? 1);
const categoryId = searchParams.categoryId ? Number(searchParams.categoryId) : undefined;
const [categories, newsRes] = await Promise.all([
publicApi.getNewsCategories().catch(() => []),
publicApi
.getNews({ page, pageSize: 10, categoryId, keyword: searchParams.keyword })
.catch(() => ({ list: [], total: 0, page, pageSize: 10 })),
]);
return (
<div className="container-page py-12">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-500"> {newsRes.total} </p>
<div className="mt-6 flex flex-wrap gap-2">
<a
href="/news"
className={`rounded-full border px-3 py-1 text-sm ${
!categoryId
? 'border-brand-600 bg-brand-50 text-brand-700'
: 'border-gray-200 text-gray-600 hover:border-brand-300'
}`}
>
</a>
{categories.map((c) => (
<a
key={c.id}
href={`/news?categoryId=${c.id}`}
className={`rounded-full border px-3 py-1 text-sm ${
categoryId === c.id
? 'border-brand-600 bg-brand-50 text-brand-700'
: 'border-gray-200 text-gray-600 hover:border-brand-300'
}`}
>
{c.name}
</a>
))}
</div>
<div className="mt-8 grid gap-4 md:grid-cols-2">
{newsRes.list.map((n) => (
<NewsCard key={n.id} news={n} />
))}
</div>
{newsRes.list.length === 0 && (
<div className="mt-8 flex h-32 items-center justify-center rounded-md border border-dashed text-sm text-gray-400">
</div>
)}
{newsRes.total > 10 && (
<div className="mt-8 flex items-center justify-center gap-2">
<a
href={`/news?${new URLSearchParams({
...(searchParams as Record<string, string>),
page: String(Math.max(1, page - 1)),
}).toString()}`}
className={`rounded border px-3 py-1 text-sm ${
page <= 1 ? 'pointer-events-none opacity-50' : 'hover:bg-gray-50'
}`}
>
</a>
<span className="text-sm text-gray-600">
{page} / {Math.ceil(newsRes.total / 10)}
</span>
<a
href={`/news?${new URLSearchParams({
...(searchParams as Record<string, string>),
page: String(page + 1),
}).toString()}`}
className="rounded border px-3 py-1 text-sm hover:bg-gray-50"
>
</a>
</div>
)}
</div>
);
}

90
app/(front)/page.tsx Normal file
View File

@ -0,0 +1,90 @@
import type { Metadata } from 'next';
import { publicApi } from '@/lib/services';
import { Hero } from '@/components/front/Hero';
import { Section } from '@/components/front/Section';
import { ProductCard } from '@/components/front/ProductCard';
import { NewsCard } from '@/components/front/NewsCard';
import { StatsBar } from '@/components/front/StatsBar';
import { FeatureGrid } from '@/components/front/FeatureGrid';
import { SolutionShowcase } from '@/components/front/SolutionShowcase';
import { CtaSection } from '@/components/front/CtaSection';
export const metadata: Metadata = {
title: '首页',
description:
'智管物业 - 让物业管理像发微信一样简单。物业缴费、在线报修、社区公告、巡检管理一站式SaaS平台。',
};
export const revalidate = 60;
async function fetchHome() {
try {
const [config, products, news] = await Promise.all([
publicApi.getSiteConfig(),
publicApi.getProducts({ page: 1, pageSize: 6 }),
publicApi.getNews({ page: 1, pageSize: 5 }),
]);
return { config, products: products.list, news: news.list };
} catch {
return { config: null, products: [], news: [] };
}
}
export default async function HomePage() {
const { config, products, news } = await fetchHome();
return (
<>
<Hero />
{/* 数据条 */}
<StatsBar />
{/* 核心功能 */}
<FeatureGrid />
{/* 解决方案 */}
<SolutionShowcase />
{/* 产品 */}
<Section
title="产品方案"
subtitle="覆盖物业管理全场景的SaaS产品矩阵"
moreHref="/products"
className="bg-slate-50"
>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
{products.length === 0 && <Empty text="暂无产品数据" />}
</Section>
{/* 新闻 */}
<Section
title="新闻资讯"
subtitle="行业动态、产品更新、物业数字化转型洞察"
moreHref="/news"
>
<div className="grid gap-4 md:grid-cols-2">
{news.map((n) => (
<NewsCard key={n.id} news={n} />
))}
</div>
{news.length === 0 && <Empty text="暂无新闻数据" />}
</Section>
{/* 转化引导 */}
<CtaSection tel={config?.tel} />
</>
);
}
function Empty({ text }: { text: string }) {
return (
<div className="flex h-32 items-center justify-center rounded-md border border-dashed border-slate-200 text-sm text-slate-400">
{text}
</div>
);
}

View File

@ -0,0 +1,68 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { publicApi } from '@/lib/services';
import { resolveUploadUrl } from '@/lib/utils';
import { ContentView } from '@/components/front/ContentView';
export const revalidate = 60;
interface PageProps {
params: { id: string };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
try {
const p = await publicApi.getProductDetail(Number(params.id));
return { title: p.name, description: p.desc ?? undefined };
} catch {
return { title: '产品详情' };
}
}
export default async function ProductDetailPage({ params }: PageProps) {
let product;
try {
product = await publicApi.getProductDetail(Number(params.id));
} catch {
notFound();
}
return (
<article className="container-page py-12">
<nav className="mb-4 text-sm text-gray-500">
<Link href="/products" className="hover:text-brand-600">
</Link>
<span className="mx-2">/</span>
<span>{product.name}</span>
</nav>
<div className="grid gap-8 md:grid-cols-2">
<div className="overflow-hidden rounded-lg bg-gray-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveUploadUrl(product.cover)}
alt={product.name}
className="aspect-[4/3] w-full object-cover"
/>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{product.name}</h1>
{product.category?.name && (
<span className="mt-2 inline-block rounded bg-brand-50 px-2 py-0.5 text-xs text-brand-700">
{product.category.name}
</span>
)}
{product.desc && (
<p className="mt-4 text-gray-700">{product.desc}</p>
)}
</div>
</div>
{product.content && (
<ContentView content={product.content} className="mt-10 max-w-4xl" />
)}
</article>
);
}

View File

@ -0,0 +1,133 @@
import type { Metadata } from 'next';
import { publicApi } from '@/lib/services';
import { ProductCard } from '@/components/front/ProductCard';
export const metadata: Metadata = {
title: '产品中心',
description: '查看我们的全部产品与解决方案。',
};
export const revalidate = 60;
interface PageProps {
searchParams: { categoryId?: string; keyword?: string; page?: string };
}
export default async function ProductsPage({ searchParams }: PageProps) {
const page = Number(searchParams.page ?? 1);
const categoryId = searchParams.categoryId ? Number(searchParams.categoryId) : undefined;
const [categories, productsRes] = await Promise.all([
publicApi.getProductCategories().catch(() => []),
publicApi
.getProducts({
page,
pageSize: 12,
categoryId,
keyword: searchParams.keyword,
})
.catch(() => ({ list: [], total: 0, page, pageSize: 12 })),
]);
return (
<div className="container-page py-12">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-500">
{productsRes.total}
</p>
{/* 分类筛选 */}
<div className="mt-6 flex flex-wrap gap-2">
<a
href="/products"
className={`rounded-full border px-3 py-1 text-sm ${
!categoryId
? 'border-brand-600 bg-brand-50 text-brand-700'
: 'border-gray-200 text-gray-600 hover:border-brand-300'
}`}
>
</a>
{categories.map((c) => (
<a
key={c.id}
href={`/products?categoryId=${c.id}`}
className={`rounded-full border px-3 py-1 text-sm ${
categoryId === c.id
? 'border-brand-600 bg-brand-50 text-brand-700'
: 'border-gray-200 text-gray-600 hover:border-brand-300'
}`}
>
{c.name}
</a>
))}
</div>
{/* 列表 */}
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{productsRes.list.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
{productsRes.list.length === 0 && (
<div className="mt-8 flex h-32 items-center justify-center rounded-md border border-dashed text-sm text-gray-400">
</div>
)}
{/* 分页 */}
{productsRes.total > 12 && (
<Pagination
page={page}
total={productsRes.total}
pageSize={12}
baseQuery={{ categoryId: searchParams.categoryId, keyword: searchParams.keyword }}
/>
)}
</div>
);
}
function Pagination({
page,
total,
pageSize,
baseQuery,
}: {
page: number;
total: number;
pageSize: number;
baseQuery: Record<string, string | undefined>;
}) {
const totalPages = Math.ceil(total / pageSize);
const buildHref = (p: number) => {
const qs = new URLSearchParams({
...(baseQuery as Record<string, string>),
page: String(p),
}).toString();
return `/products?${qs}`;
};
return (
<div className="mt-8 flex items-center justify-center gap-2">
<a
href={buildHref(Math.max(1, page - 1))}
className={`rounded border px-3 py-1 text-sm ${
page <= 1 ? 'pointer-events-none opacity-50' : 'hover:bg-gray-50'
}`}
>
</a>
<span className="text-sm text-gray-600">
{page} / {totalPages}
</span>
<a
href={buildHref(Math.min(totalPages, page + 1))}
className={`rounded border px-3 py-1 text-sm ${
page >= totalPages ? 'pointer-events-none opacity-50' : 'hover:bg-gray-50'
}`}
>
</a>
</div>
);
}

48
app/(front)/team/page.tsx Normal file
View File

@ -0,0 +1,48 @@
import type { Metadata } from 'next';
import { publicApi } from '@/lib/services';
import { resolveUploadUrl } from '@/lib/utils';
export const metadata: Metadata = {
title: '团队介绍',
description: '认识我们的核心团队成员。',
};
export const revalidate = 60;
export default async function TeamPage() {
const team = await publicApi.getTeam().catch(() => []);
return (
<div className="container-page py-12">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-500">
</p>
<div className="mt-10 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
{team.map((m) => (
<div key={m.id} className="text-center">
<div className="mx-auto h-32 w-32 overflow-hidden rounded-full bg-gray-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveUploadUrl(m.avatar)}
alt={m.name}
className="h-full w-full object-cover"
/>
</div>
<h3 className="mt-4 text-lg font-semibold text-gray-900">
{m.name}
</h3>
<p className="text-sm text-brand-600">{m.position}</p>
{m.desc && (
<p className="mt-2 text-sm text-gray-500">{m.desc}</p>
)}
</div>
))}
</div>
{team.length === 0 && (
<div className="mt-8 flex h-32 items-center justify-center rounded-md border border-dashed text-sm text-gray-400">
</div>
)}
</div>
);
}

View File

@ -0,0 +1,568 @@
'use client';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import {
Plus,
Pencil,
Trash2,
KeyRound,
Loader2,
ShieldCheck,
Crown,
} from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { PaginationTable, type Column } from '@/components/admin/PaginationTable';
import { TableToolbar } from '@/components/admin/TableToolbar';
import { Avatar } from '@/components/admin/Avatar';
import { ImageUpload } from '@/components/admin/ImageUpload';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { formatDate } from '@/lib/utils';
import type { AdminRole, AdminUser } from '@/lib/types';
interface CreateForm {
username: string;
password: string;
nickname: string;
avatar: string;
role: AdminRole;
}
interface EditForm {
nickname: string;
avatar: string;
role: AdminRole;
}
const CREATE_DEFAULT: CreateForm = {
username: '',
password: '',
nickname: '',
avatar: '',
role: 'normal',
};
const ROLE_LABEL: Record<AdminRole, string> = {
super_admin: '超级管理员',
normal: '普通管理员',
};
export default function AdminUserPage() {
const [hydrated, setHydrated] = useState(false);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const [searchKw, setSearchKw] = useState('');
// 创建/编辑
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [createForm, setCreateForm] = useState<CreateForm>(CREATE_DEFAULT);
const [editForm, setEditForm] = useState<EditForm>({
nickname: '',
avatar: '',
role: 'normal',
});
const [saving, setSaving] = useState(false);
// 重置密码
const [pwdOpen, setPwdOpen] = useState(false);
const [pwdId, setPwdId] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState('');
const [pwdConfirm, setPwdConfirm] = useState('');
const [pwdSaving, setPwdSaving] = useState(false);
const currentAdminId = useAdminStore((s) => s.admin?.id);
const currentAdminRole = useAdminStore((s) => s.admin?.role);
const isSuperAdmin = currentAdminRole === 'super_admin';
useEffect(() => setHydrated(true), []);
const { data, isLoading, mutate } = useSWR(
hydrated ? ['/admin/admin-user', page, searchKw] : null,
() =>
adminApi.adminUserPaginate({
page,
pageSize: 10,
keyword: searchKw || undefined,
}),
);
const openCreate = () => {
setEditId(null);
setCreateForm(CREATE_DEFAULT);
setOpen(true);
};
const openEdit = async (id: number) => {
const detail = await adminApi.adminUserDetail(id);
setEditId(id);
setEditForm({
nickname: detail.nickname,
avatar: detail.avatar ?? '',
role: detail.role ?? 'normal',
});
setOpen(true);
};
const onSave = async () => {
if (editId) {
if (!editForm.nickname.trim()) {
alert('请填写名称');
return;
}
setSaving(true);
try {
await adminApi.adminUserUpdate(editId, {
nickname: editForm.nickname.trim(),
avatar: editForm.avatar,
role: editForm.role,
});
setOpen(false);
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
return;
}
// create
if (!createForm.username.trim() || !createForm.password || !createForm.nickname.trim()) {
alert('请填写登录账号、初始密码和名称');
return;
}
setSaving(true);
try {
await adminApi.adminUserCreate({
username: createForm.username.trim(),
password: createForm.password,
nickname: createForm.nickname.trim(),
avatar: createForm.avatar,
role: createForm.role,
});
setOpen(false);
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
};
const onDelete = async (id: number) => {
if (!confirm('确认删除该管理员账号?此操作不可恢复。')) return;
try {
await adminApi.adminUserDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
const openResetPassword = (id: number) => {
setPwdId(id);
setNewPassword('');
setPwdConfirm('');
setPwdOpen(true);
};
const onResetPassword = async () => {
if (!pwdId) return;
if (newPassword.length < 6) {
alert('新密码至少 6 位');
return;
}
if (newPassword !== pwdConfirm) {
alert('两次输入的密码不一致');
return;
}
setPwdSaving(true);
try {
await adminApi.adminUserResetPassword(pwdId, newPassword);
setPwdOpen(false);
} catch (e) {
alert((e as Error).message);
} finally {
setPwdSaving(false);
}
};
const columns: Column<AdminUser>[] = [
{ key: 'id', title: 'ID', width: 60 },
{
key: 'avatar',
title: '头像',
width: 80,
render: (r) => <Avatar src={r.avatar} name={r.nickname} size={36} />,
},
{
key: 'username',
title: '登录账号',
width: 200,
render: (r) => (
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{r.username}</span>
{r.id === currentAdminId && (
<Badge variant="success">
<ShieldCheck className="h-3 w-3" />
</Badge>
)}
</div>
),
},
{ key: 'nickname', title: '名称', width: 140 },
{
key: 'role',
title: '角色',
width: 130,
render: (r) =>
r.role === 'super_admin' ? (
<Badge variant="warning">
<Crown className="h-3 w-3" /> {ROLE_LABEL[r.role]}
</Badge>
) : (
<Badge variant="secondary">{ROLE_LABEL[r.role]}</Badge>
),
},
{
key: 'createdAt',
title: '创建时间',
width: 160,
render: (r) => formatDate(r.createdAt, 'YYYY-MM-DD HH:mm'),
},
{
key: '_op',
title: '操作',
width: 240,
render: (r) =>
isSuperAdmin ? (
<div className="flex items-center gap-1.5 whitespace-nowrap">
<Button size="sm" variant="outline" onClick={() => openEdit(r.id)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openResetPassword(r.id)}
>
<KeyRound className="h-3 w-3" />
</Button>
{r.id !== currentAdminId && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
) : (
<span className="text-xs text-gray-400"></span>
),
},
];
return (
<div>
<AdminHeader
title="管理员账号"
description="维护可登录后台的员工账号(支持手机号/邮箱/自定义账号)"
/>
<TableToolbar
left={
<>
<Input
placeholder="搜索 登录账号/名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchKw(keyword);
setPage(1);
}
}}
className="max-w-xs"
/>
<Button
variant="outline"
onClick={() => {
setSearchKw(keyword);
setPage(1);
}}
>
</Button>
</>
}
right={
isSuperAdmin ? (
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
</Button>
) : null
}
/>
<PaginationTable<AdminUser>
columns={columns}
rows={data?.list ?? []}
total={data?.total ?? 0}
page={page}
pageSize={10}
loading={isLoading}
onPageChange={setPage}
rowKey={(r) => String(r.id)}
/>
{/* 创建 / 编辑弹窗 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{editId ? '编辑管理员账号' : '新增管理员账号'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{editId ? (
<>
<div className="space-y-1.5">
<Label></Label>
<Input
value={
data?.list.find((x) => x.id === editId)?.username ?? ''
}
disabled
/>
<p className="text-xs text-gray-500">
</p>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={editForm.nickname}
onChange={(e) =>
setEditForm({ ...editForm, nickname: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<ImageUpload
value={editForm.avatar}
onChange={(url) =>
setEditForm({ ...editForm, avatar: url })
}
hint="建议正方形头像,单图 ≤ 2M"
/>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="edit-role"
value="normal"
checked={editForm.role === 'normal'}
onChange={() =>
setEditForm({ ...editForm, role: 'normal' })
}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="edit-role"
value="super_admin"
checked={editForm.role === 'super_admin'}
onChange={() =>
setEditForm({ ...editForm, role: 'super_admin' })
}
/>
</label>
</div>
{editId === currentAdminId && (
<p className="text-xs text-amber-600">
</p>
)}
</div>
</>
) : (
<>
<div className="space-y-1.5">
<Label> *</Label>
<Input
placeholder="可使用手机号、邮箱或自定义账号"
value={createForm.username}
maxLength={50}
onChange={(e) =>
setCreateForm({
...createForm,
username: e.target.value,
})
}
/>
<p className="text-xs text-gray-500">
2-50
</p>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
type="password"
placeholder="至少 6 位"
value={createForm.password}
onChange={(e) =>
setCreateForm({
...createForm,
password: e.target.value,
})
}
/>
<p className="text-xs text-gray-500">
</p>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
placeholder="员工姓名或别名"
value={createForm.nickname}
maxLength={50}
onChange={(e) =>
setCreateForm({
...createForm,
nickname: e.target.value,
})
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<ImageUpload
value={createForm.avatar}
onChange={(url) =>
setCreateForm({ ...createForm, avatar: url })
}
hint="可选,建议正方形头像,单图 ≤ 2M"
/>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="create-role"
value="normal"
checked={createForm.role === 'normal'}
onChange={() =>
setCreateForm({ ...createForm, role: 'normal' })
}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="create-role"
value="super_admin"
checked={createForm.role === 'super_admin'}
onChange={() =>
setCreateForm({ ...createForm, role: 'super_admin' })
}
/>
</label>
</div>
<p className="text-xs text-gray-500">
</p>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 重置密码弹窗 */}
<Dialog open={pwdOpen} onOpenChange={setPwdOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label> *</Label>
<Input
type="password"
placeholder="至少 6 位"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
type="password"
placeholder="再次输入新密码"
value={pwdConfirm}
onChange={(e) => setPwdConfirm(e.target.value)}
/>
</div>
<p className="text-xs text-gray-500">
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPwdOpen(false)}>
</Button>
<Button onClick={onResetPassword} disabled={pwdSaving}>
{pwdSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'确认重置'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,239 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import useSWR from 'swr';
import {
Newspaper,
Package,
MessageSquare,
Users,
Settings,
ArrowUpRight,
ExternalLink,
ChevronRight,
} from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { Card, CardContent } from '@/components/ui/card';
import { adminApi } from '@/lib/admin-services';
import type { Message } from '@/lib/types';
export default function DashboardPage() {
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
const { data: productCount } = useSWR<number>(
hydrated ? 'dashboard:product-count' : null,
() =>
adminApi.productPaginate({ page: 1, pageSize: 1 }).then((r) => r.total),
);
const { data: newsCount } = useSWR<number>(
hydrated ? 'dashboard:news-count' : null,
() => adminApi.newsPaginate({ page: 1, pageSize: 1 }).then((r) => r.total),
);
const { data: teamCount } = useSWR<number>(
hydrated ? 'dashboard:team-count' : null,
() => adminApi.teamAll().then((r) => r.length),
);
const { data: message } = useSWR<{ total: number; unread: number }>(
hydrated ? 'dashboard:message-summary' : null,
() =>
adminApi
.messagePaginate({ page: 1, pageSize: 100 })
.then((r) => ({
total: r.total,
unread: r.list.filter((m: Message) => m.isRead === 0).length,
})),
);
const stats = [
{
href: '/admin/product',
label: '产品管理',
value: productCount,
icon: <Package className="h-5 w-5" />,
},
{
href: '/admin/news',
label: '新闻资讯',
value: newsCount,
icon: <Newspaper className="h-5 w-5" />,
},
{
href: '/admin/team',
label: '团队成员',
value: teamCount,
icon: <Users className="h-5 w-5" />,
},
{
href: '/admin/message',
label: '客户留言',
value: message?.total,
icon: <MessageSquare className="h-5 w-5" />,
badge:
message && message.unread > 0 ? `${message.unread} 未读` : undefined,
},
];
const quickActions = [
{
href: '/admin/product',
label: '发布产品方案',
hint: '新增 / 编辑产品',
icon: <Package className="h-5 w-5" />,
},
{
href: '/admin/news',
label: '发布新闻动态',
hint: '撰写行业资讯',
icon: <Newspaper className="h-5 w-5" />,
},
{
href: '/admin/team',
label: '维护团队',
hint: '成员信息',
icon: <Users className="h-5 w-5" />,
},
{
href: '/admin/site-config',
label: '站点配置',
hint: '基础信息 / 联系方式',
icon: <Settings className="h-5 w-5" />,
},
];
return (
<div>
<AdminHeader title="仪表盘" description="网站核心数据一览,快速进入各管理模块" />
{/* ===== 数据统计卡 ===== */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{stats.map((s) => (
<Stat key={s.href} {...s} />
))}
</div>
{/* ===== 主体两栏 ===== */}
<div className="mt-6 grid gap-6 lg:grid-cols-3">
{/* 快捷操作 */}
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-slate-900"></h2>
<span className="text-xs text-slate-400"></span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{quickActions.map((a) => (
<Link
key={a.href}
href={a.href}
className="group flex items-center gap-3 rounded-xl border border-slate-200/70 bg-white p-4 shadow-[0_1px_3px_rgba(15,23,42,0.04)] transition-all duration-300 hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-[0_12px_28px_-12px_rgba(79,70,229,0.22)]"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-slate-800 to-slate-900 text-white shadow-md shadow-slate-900/20 ring-1 ring-inset ring-white/10">
{a.icon}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-slate-900">
{a.label}
</div>
<div className="mt-0.5 text-xs text-slate-400">{a.hint}</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-slate-300 transition-all group-hover:translate-x-0.5 group-hover:text-brand-500" />
</Link>
))}
</div>
</div>
{/* 系统信息 / 访问地址 */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-slate-900">访</h2>
<span className="text-xs text-slate-400"></span>
</div>
<Card className="border-slate-200/70 shadow-[0_1px_3px_rgba(15,23,42,0.04)]">
<CardContent className="space-y-3 p-5">
<a
href="http://localhost:3000"
target="_blank"
rel="noreferrer"
className="group flex items-center gap-3 rounded-lg border border-slate-200/70 p-3 transition-colors hover:border-brand-200 hover:bg-brand-50/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
<ExternalLink className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-slate-900">
</div>
<div className="truncate text-xs text-slate-400">
http://localhost:3000
</div>
</div>
<ArrowUpRight className="h-4 w-4 text-slate-300 group-hover:text-brand-500" />
</a>
<a
href="http://localhost:3001/api-docs"
target="_blank"
rel="noreferrer"
className="group flex items-center gap-3 rounded-lg border border-slate-200/70 p-3 transition-colors hover:border-brand-200 hover:bg-brand-50/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
<ExternalLink className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-slate-900">
Swagger
</div>
<div className="truncate text-xs text-slate-400">
http://localhost:3001/api-docs
</div>
</div>
<ArrowUpRight className="h-4 w-4 text-slate-300 group-hover:text-brand-500" />
</a>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function Stat({
href,
label,
value,
icon,
badge,
}: {
href: string;
label: string;
value: number | undefined;
icon: React.ReactNode;
badge?: string;
}) {
return (
<Link href={href}>
<Card className="group relative overflow-hidden border-slate-200/70 shadow-[0_1px_3px_rgba(15,23,42,0.04),0_8px_24px_-12px_rgba(79,70,229,0.1)] transition-all duration-300 hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-[0_4px_12px_rgba(15,23,42,0.06),0_20px_40px_-16px_rgba(79,70,229,0.22)]">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
{icon}
</span>
{badge ? (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-600">
{badge}
</span>
) : (
<ArrowUpRight className="h-4 w-4 text-slate-300 transition-colors group-hover:text-brand-500" />
)}
</div>
<div className="mt-4 flex items-baseline gap-1">
<span className="text-3xl font-bold tabular-nums text-slate-900">
{value ?? '—'}
</span>
</div>
<div className="mt-1 text-sm text-slate-500">{label}</div>
</CardContent>
</Card>
</Link>
);
}

11
app/admin/layout.tsx Normal file
View File

@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import { AdminShell } from '@/components/admin/AdminShell';
export const metadata = {
title: { default: '后台管理', template: '%s - 后台管理' },
robots: { index: false, follow: false },
};
export default function AdminLayout({ children }: { children: ReactNode }) {
return <AdminShell>{children}</AdminShell>;
}

16
app/admin/loading.tsx Normal file
View File

@ -0,0 +1,16 @@
import { Loader2 } from 'lucide-react';
/**
* Next.js App Router Suspense fallback
* chunk spinner/
*/
export default function AdminLoading() {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
<span className="text-sm text-slate-400"></span>
</div>
</div>
);
}

206
app/admin/login/page.tsx Normal file
View File

@ -0,0 +1,206 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Building2,
CheckCircle2,
User,
Lock,
Loader2,
} from 'lucide-react';
const schema = z.object({
username: z.string().min(1, '请输入账号'),
password: z.string().min(1, '请输入密码'),
});
type FormData = z.infer<typeof schema>;
export default function AdminLoginPage() {
const router = useRouter();
const search = useSearchParams();
const { setLogin, token } = useAdminStore();
const [serverError, setServerError] = useState<string | null>(null);
// 已登录则直接跳到后台
useEffect(() => {
if (token) {
const redirect = search.get('redirect');
router.replace(redirect || '/admin/dashboard');
}
}, [token, router, search]);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { username: 'admin', password: '' },
});
const onSubmit = async (data: FormData) => {
setServerError(null);
try {
const res = await adminApi.login(data);
setLogin(res.token, res.admin);
const redirect = search.get('redirect');
router.replace(redirect || '/admin/dashboard');
} catch (e) {
setServerError((e as Error).message);
}
};
return (
<div className="grid min-h-screen lg:grid-cols-2">
{/* ===== 左侧:品牌展示区(深色,呼应首页 Hero===== */}
<div className="relative hidden overflow-hidden bg-[#0a0e27] lg:flex lg:flex-col lg:justify-between lg:p-12">
{/* 渐变底 + 辉光 + 几何图形 */}
<div className="pointer-events-none absolute inset-0">
<div className="absolute inset-0 bg-gradient-to-br from-[#0a0e27] via-[#0d1130] to-[#0a0e27]" />
<div className="absolute -left-20 top-0 h-72 w-72 rounded-full bg-brand-600/15 blur-[120px]" />
<div className="absolute bottom-0 right-0 h-80 w-80 rounded-full bg-brand-700/15 blur-[120px]" />
<div
className="hero-anim-spin absolute right-10 top-16 h-32 w-32 rounded-full border-2 border-dashed border-white/10"
style={{ animationDuration: '24s' }}
/>
<div className="hero-anim-float absolute bottom-20 left-[8%] h-12 w-12 rounded-lg border border-white/10 bg-white/[0.03]" />
<div className="hero-anim-pulse absolute left-[18%] top-[28%] h-2.5 w-2.5 rounded-full bg-brand-400/40" />
<div className="hero-anim-float-rev absolute right-[14%] bottom-[24%] rotate-45 h-8 w-8 rounded-sm border border-white/10 bg-white/[0.03]" />
<div className="hero-anim-float absolute right-[22%] top-[20%] text-2xl font-light text-white/10">+</div>
</div>
{/* Logo */}
<div className="relative z-10 flex items-center gap-2">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-lg shadow-brand-600/30 ring-1 ring-inset ring-white/10">
<Building2 className="h-5 w-5" />
</span>
<span className="text-lg font-bold text-white"></span>
</div>
{/* 标语 */}
<div className="relative z-10">
<h2 className="text-3xl font-bold leading-tight text-white xl:text-4xl">
<br />
<span className="bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">
</span>
</h2>
<p className="mt-4 text-lg text-slate-400">&ldquo;&rdquo;</p>
<div className="mt-10 space-y-3">
{[
'一站式物业管理 SaaS 平台',
'覆盖缴费、报修、公告、巡检全流程',
'三端协同,助力物业降本增效',
].map((t) => (
<div key={t} className="flex items-center gap-2.5 text-sm text-slate-300">
<CheckCircle2 className="h-4 w-4 shrink-0 text-brand-400" />
{t}
</div>
))}
</div>
</div>
{/* 版权 */}
<div className="relative z-10 text-xs text-slate-500">
© {new Date().getFullYear()} · SaaS
</div>
</div>
{/* ===== 右侧:表单区(干净浅色)===== */}
<div className="flex min-h-screen items-center justify-center bg-white px-6 py-12">
<div className="w-full max-w-sm">
{/* 移动端 Logo左侧面板在小屏隐藏*/}
<div className="mb-10 flex items-center gap-2 lg:hidden">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-slate-800 to-slate-900 text-white shadow-md shadow-slate-900/25 ring-1 ring-inset ring-white/10">
<Building2 className="h-5 w-5" />
</span>
<span className="text-lg font-bold text-slate-900"></span>
</div>
<div className="mb-8">
<span className="text-xs font-semibold uppercase tracking-widest text-brand-600">
Admin
</span>
<h1 className="mt-2 text-2xl font-bold text-slate-900"></h1>
<p className="mt-1.5 text-sm text-slate-500"></p>
</div>
{search.get('expired') && (
<div className="mb-5 rounded-lg border border-yellow-200 bg-yellow-50 px-3.5 py-2.5 text-sm text-yellow-700">
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit(onSubmit)(e);
}}
className="space-y-5"
>
<div className="space-y-1.5">
<Label htmlFor="username"></Label>
<div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
id="username"
className="pl-9"
placeholder="请输入账号"
{...register('username')}
/>
</div>
{errors.username && (
<p className="text-xs text-red-600">{errors.username.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password"></Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
id="password"
type="password"
className="pl-9"
placeholder="请输入密码"
{...register('password')}
/>
</div>
{errors.password && (
<p className="text-xs text-red-600">{errors.password.message}</p>
)}
</div>
{serverError && (
<p className="text-sm text-red-600">{serverError}</p>
)}
<Button
type="submit"
className="w-full bg-slate-900 text-white hover:bg-black"
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</form>
<p className="mt-8 text-center text-xs text-slate-400">
admin / 123456
</p>
</div>
</div>
</div>
);
}

511
app/admin/manual/page.tsx Normal file
View File

@ -0,0 +1,511 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { Plus, Pencil, Trash2, Folder, FileText, Loader2 } from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { TableToolbar } from '@/components/admin/TableToolbar';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { RichEditor } from '@/components/admin/RichEditor';
import { MarkdownImport } from '@/components/admin/MarkdownImport';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { cn } from '@/lib/utils';
import { isHTMLContent } from '@/lib/content';
import type { Manual, ManualContentFormat, ManualNodeType, ManualTreeNode } from '@/lib/types';
type ContentMode = 'rich' | 'markdown';
interface FormState {
parentId: number | null;
title: string;
type: ManualNodeType;
content: string;
sort: number;
isShow: number;
}
const FORM_DEFAULT: FormState = {
parentId: null,
title: '',
type: 1,
content: '',
sort: 0,
isShow: 1,
};
/** 将树形结构展平为带缩进层级的列表,便于在表格中展示 */
interface FlatRow extends Manual {
_depth: number;
}
function flattenTree(nodes: ManualTreeNode[], depth = 0, acc: FlatRow[] = []): FlatRow[] {
for (const n of nodes) {
acc.push({
id: n.id,
parentId: n.parentId,
title: n.title,
type: n.type,
content: null,
contentFormat: 'html',
sort: n.sort,
isShow: n.isShow,
createdAt: '',
updatedAt: '',
_depth: depth,
});
if (n.children.length > 0) {
flattenTree(n.children, depth + 1, acc);
}
}
return acc;
}
/** 生成父节点下拉的缩进选项(排除 selfId 及其子孙) */
function buildParentOptions(
nodes: ManualTreeNode[],
selfId: number | null,
): { id: number; label: string; depth: number }[] {
const options: { id: number; label: string; depth: number }[] = [];
const walk = (list: ManualTreeNode[], depth: number) => {
for (const n of list) {
if (n.id === selfId) continue;
options.push({ id: n.id, label: n.title, depth });
if (n.children.length > 0) walk(n.children, depth + 1);
}
};
walk(nodes, 0);
return options;
}
export default function ManualAdminPage() {
const [hydrated, setHydrated] = useState(false);
const [keyword, setKeyword] = useState('');
const [searchKw, setSearchKw] = useState('');
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [contentMode, setContentMode] = useState<ContentMode>('rich');
const [form, setForm] = useState<FormState>(FORM_DEFAULT);
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
useEffect(() => setHydrated(true), []);
const { data: tree, isLoading, mutate } = useSWR<ManualTreeNode[]>(
hydrated ? '/admin/manual/tree' : null,
() => adminApi.manualTree(),
);
const rows = useMemo(() => {
const flat = flattenTree(tree ?? []);
if (!searchKw) return flat;
return flat.filter((r) => r.title.toLowerCase().includes(searchKw.toLowerCase()));
}, [tree, searchKw]);
const parentOptions = useMemo(
() => buildParentOptions(tree ?? [], editId),
[tree, editId],
);
const openCreate = (presetParentId?: number | null) => {
setEditId(null);
setContentMode('rich');
setForm({ ...FORM_DEFAULT, parentId: presetParentId ?? null });
setOpen(true);
};
const openEdit = async (id: number) => {
const detail = await adminApi.manualDetail(id);
setEditId(id);
// 优先使用持久化的 contentFormat旧数据无该字段时回退到内容探测
const detected =
detail.contentFormat === 'markdown'
? 'markdown'
: detail.content && isHTMLContent(detail.content)
? 'rich'
: 'markdown';
setContentMode(detected);
setForm({
parentId: detail.parentId,
title: detail.title,
type: detail.type,
content: detail.content ?? '',
sort: detail.sort,
isShow: detail.isShow,
});
setOpen(true);
};
const onSave = async () => {
if (!form.title.trim()) {
alert('请填写标题');
return;
}
setSaving(true);
try {
const contentFormat: ManualContentFormat =
form.type === 0 || contentMode !== 'markdown' ? 'html' : 'markdown';
const payload: Partial<Manual> = {
parentId: form.parentId,
title: form.title.trim(),
type: form.type,
content: form.type === 0 ? null : form.content,
contentFormat,
sort: form.sort,
isShow: form.isShow,
};
if (editId) {
await adminApi.manualUpdate(editId, payload);
} else {
await adminApi.manualCreate(payload);
}
setOpen(false);
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
};
const onDelete = async (id: number) => {
if (!confirm('确认删除该节点?若其下有子节点需先删除子节点。')) return;
try {
await adminApi.manualDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
return (
<div>
<AdminHeader
title="使用手册"
description="维护多级树形使用手册(目录 + 文档,文档支持富文本/Markdown"
/>
<TableToolbar
left={
<>
<Input
placeholder="搜索标题"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') setSearchKw(keyword);
}}
className="max-w-xs"
/>
<Button variant="outline" onClick={() => setSearchKw(keyword)}>
</Button>
</>
}
right={
<Button onClick={() => openCreate(null)}>
<Plus className="h-4 w-4" />
</Button>
}
/>
<div className="mt-4 rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead style={{ width: 60 }}>ID</TableHead>
<TableHead> / </TableHead>
<TableHead style={{ width: 100 }}></TableHead>
<TableHead style={{ width: 80 }}></TableHead>
<TableHead style={{ width: 260 }}></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={5} className="h-32">
<div className="flex items-center justify-center gap-2 text-gray-400">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm"></span>
</div>
</TableCell>
</TableRow>
)}
{!isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="h-32 text-center text-gray-400">
</TableCell>
</TableRow>
)}
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="text-gray-500">{r.id}</TableCell>
<TableCell>
<div className="flex items-center">
{/* 缩进引导线:每层级一条垂直线 */}
{Array.from({ length: r._depth }).map((_, i) => (
<span key={i} className="flex w-6 justify-center">
<span className="h-6 w-px bg-gray-200" />
</span>
))}
{/* 节点图标:目录=琥珀色底,文档=灰色底 */}
<span
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
r.type === 0 ? 'bg-amber-50' : 'bg-slate-50',
)}
>
{r.type === 0 ? (
<Folder className="h-4 w-4 text-amber-500" />
) : (
<FileText className="h-4 w-4 text-slate-400" />
)}
</span>
<span
className={cn(
'ml-2',
r.type === 0
? 'font-semibold text-gray-900'
: 'font-medium text-gray-600',
)}
>
{r.title}
</span>
</div>
</TableCell>
<TableCell>
{r.type === 0 ? (
<Badge variant="warning"></Badge>
) : (
<Badge variant="secondary"></Badge>
)}
</TableCell>
<TableCell>
{r.isShow === 1 ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 whitespace-nowrap">
{r.type === 0 && (
<Button
size="sm"
variant="outline"
onClick={() => openCreate(r.id)}
>
<Plus className="h-3 w-3" />
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => openEdit(r.id)}
>
<Pencil className="h-3 w-3" />
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 新增 / 编辑节点 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{editId ? '编辑节点' : '新增节点'}</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> *</Label>
<div className="flex items-center gap-4 pt-1">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="node-type"
checked={form.type === 0}
onChange={() => setForm({ ...form, type: 0 })}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="node-type"
checked={form.type === 1}
onChange={() => setForm({ ...form, type: 1 })}
/>
</label>
</div>
</div>
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.parentId ?? ''}
onChange={(e) =>
setForm({
...form,
parentId: e.target.value ? Number(e.target.value) : null,
})
}
>
<option value=""></option>
{parentOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{`${' '.repeat(opt.depth)}${opt.depth > 0 ? '└ ' : ''}${opt.label}`}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.title}
maxLength={200}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder={form.type === 0 ? '目录名称' : '文档标题'}
/>
</div>
{form.type === 1 && (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label></Label>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-gray-200 p-0.5">
<button
type="button"
onClick={() => setContentMode('rich')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'rich'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
</button>
<button
type="button"
onClick={() => setContentMode('markdown')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'markdown'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
Markdown
</button>
</div>
{contentMode === 'markdown' && (
<MarkdownImport
onImport={(md) => setForm({ ...form, content: md })}
onError={(msg) => alert(msg)}
/>
)}
</div>
</div>
{contentMode === 'rich' ? (
<RichEditor
value={form.content}
onChange={(html) => setForm({ ...form, content: html })}
/>
) : (
<Textarea
rows={12}
placeholder="支持 Markdown 语法。可点击右上角上传 .md 文档"
className="font-mono text-sm"
value={form.content}
onChange={(e) =>
setForm({ ...form, content: e.target.value })
}
/>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) =>
setForm({ ...form, sort: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.isShow}
onChange={(e) =>
setForm({ ...form, isShow: Number(e.target.value) })
}
>
<option value={1}></option>
<option value={0}></option>
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

208
app/admin/message/page.tsx Normal file
View File

@ -0,0 +1,208 @@
'use client';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { CheckCircle2, CheckCheck, Loader2, Trash2 } from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { PaginationTable, type Column } from '@/components/admin/PaginationTable';
import { TableToolbar } from '@/components/admin/TableToolbar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { formatDate } from '@/lib/utils';
import type { Message } from '@/lib/types';
export default function MessageAdminPage() {
const [hydrated, setHydrated] = useState(false);
const [page, setPage] = useState(1);
const [isRead, setIsRead] = useState<number | undefined>();
const [keyword, setKeyword] = useState('');
const [searchKw, setSearchKw] = useState('');
const [markingAll, setMarkingAll] = useState(false);
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
useEffect(() => setHydrated(true), []);
const { data, isLoading, mutate } = useSWR(
hydrated ? ['/admin/message', page, isRead, searchKw] : null,
() =>
adminApi.messagePaginate({
page,
pageSize: 10,
isRead,
keyword: searchKw || undefined,
}),
);
const onMarkRead = async (id: number) => {
try {
await adminApi.messageMarkRead(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
const onDelete = async (id: number) => {
if (!confirm('确认删除该留言?')) return;
try {
await adminApi.messageDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
const onMarkAllRead = async () => {
setMarkingAll(true);
try {
// 拉取全部未读(跨页)
const unread = await adminApi.messagePaginate({
isRead: 0,
page: 1,
pageSize: 999,
});
// 串行标记,单个失败即中断
for (const m of unread.list) {
await adminApi.messageMarkRead(m.id);
}
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setMarkingAll(false);
}
};
const hasUnread = (data?.list ?? []).some((m) => m.isRead === 0);
const columns: Column<Message>[] = [
{ key: 'id', title: 'ID', width: 60 },
{ key: 'name', title: '姓名', width: 120 },
{ key: 'phone', title: '电话', width: 140 },
{ key: 'email', title: '邮箱', width: 180 },
{
key: 'content',
title: '留言内容',
render: (r) => (
<span className="line-clamp-2 max-w-md text-xs text-gray-700">
{r.content}
</span>
),
},
{
key: 'createdAt',
title: '提交时间',
width: 160,
render: (r) => formatDate(r.createdAt, 'YYYY-MM-DD HH:mm'),
},
{
key: 'isRead',
title: '状态',
width: 90,
render: (r) =>
r.isRead === 1 ? (
<Badge variant="outline"></Badge>
) : (
<Badge variant="warning"></Badge>
),
},
{
key: '_op',
title: '操作',
width: 150,
render: (r) => (
<div className="flex items-center gap-1.5 whitespace-nowrap">
{r.isRead === 0 && (
<Button size="sm" variant="outline" onClick={() => onMarkRead(r.id)}>
<CheckCircle2 className="h-3 w-3" />
</Button>
)}
{isSuperAdmin && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
),
},
];
return (
<div>
<AdminHeader title="留言管理" description="查看访客提交的咨询留言" />
<TableToolbar
left={
<>
<select
className="h-10 rounded-md border border-gray-300 px-3 text-sm"
value={isRead ?? ''}
onChange={(e) => {
setIsRead(e.target.value === '' ? undefined : Number(e.target.value));
setPage(1);
}}
>
<option value=""></option>
<option value="0"></option>
<option value="1"></option>
</select>
<Input
placeholder="搜索 姓名/电话/内容"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchKw(keyword);
setPage(1);
}
}}
className="max-w-xs"
/>
<Button
variant="outline"
onClick={() => {
setSearchKw(keyword);
setPage(1);
}}
>
</Button>
</>
}
right={
<Button
variant="outline"
onClick={onMarkAllRead}
disabled={!hasUnread || markingAll}
>
{markingAll ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCheck className="h-4 w-4" />
)}
</Button>
}
/>
<PaginationTable<Message>
columns={columns}
rows={data?.list ?? []}
total={data?.total ?? 0}
page={page}
pageSize={10}
loading={isLoading}
onPageChange={setPage}
rowKey={(r) => String(r.id)}
/>
</div>
);
}

519
app/admin/news/page.tsx Normal file
View File

@ -0,0 +1,519 @@
'use client';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { Plus, Pencil, Trash2, Tags, Loader2 } from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { PaginationTable, type Column } from '@/components/admin/PaginationTable';
import { TableToolbar } from '@/components/admin/TableToolbar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ImageUpload } from '@/components/admin/ImageUpload';
import { RichEditor } from '@/components/admin/RichEditor';
import { MarkdownImport } from '@/components/admin/MarkdownImport';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { cn, formatDate, resolveUploadUrl } from '@/lib/utils';
import { isHTMLContent } from '@/lib/content';
import type { News, NewsCategory } from '@/lib/types';
type ContentMode = 'rich' | 'markdown';
interface FormState {
categoryId: number;
title: string;
cover: string;
intro: string;
content: string;
isTop: number;
status: number;
}
export default function NewsAdminPage() {
const [hydrated, setHydrated] = useState(false);
const [page, setPage] = useState(1);
const [categoryId, setCategoryId] = useState<number | undefined>();
const [keyword, setKeyword] = useState('');
const [searchKw, setSearchKw] = useState('');
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [contentMode, setContentMode] = useState<ContentMode>('rich');
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
const [catOpen, setCatOpen] = useState(false);
const [catName, setCatName] = useState('');
const [catSort, setCatSort] = useState(0);
const [form, setForm] = useState<FormState>({
categoryId: 0,
title: '',
cover: '',
intro: '',
content: '',
isTop: 0,
status: 1,
});
useEffect(() => setHydrated(true), []);
const { data: categories, mutate: mutateCats } = useSWR<NewsCategory[]>(
hydrated ? '/admin/news-category' : null,
() => adminApi.newsCategoryAll(),
);
const { data, isLoading, mutate } = useSWR(
hydrated ? ['/admin/news', page, categoryId, searchKw] : null,
() =>
adminApi.newsPaginate({
page,
pageSize: 10,
categoryId,
keyword: searchKw || undefined,
}),
);
const openCreate = () => {
if (!categories || categories.length === 0) {
alert('请先创建新闻分类');
setCatOpen(true);
return;
}
setEditId(null);
setContentMode('rich');
setForm({
categoryId: categories[0].id,
title: '',
cover: '',
intro: '',
content: '',
isTop: 0,
status: 1,
});
setOpen(true);
};
const openEdit = async (id: number) => {
const detail = await adminApi.newsDetail(id);
setEditId(id);
setContentMode(isHTMLContent(detail.content) ? 'rich' : 'markdown');
setForm({
categoryId: detail.categoryId,
title: detail.title,
cover: detail.cover,
intro: detail.intro,
content: detail.content,
isTop: detail.isTop,
status: detail.status,
});
setOpen(true);
};
const onSave = async () => {
if (!form.title || !form.content) {
alert('请填写标题和正文');
return;
}
setSaving(true);
try {
const payload = { ...form, categoryId: Number(form.categoryId) };
if (editId) {
await adminApi.newsUpdate(editId, payload);
} else {
await adminApi.newsCreate(payload);
}
setOpen(false);
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
};
const onDelete = async (id: number) => {
if (!confirm('确认删除该新闻?')) return;
try {
await adminApi.newsDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
const onAddCategory = async () => {
if (!catName.trim()) {
alert('请输入分类名称');
return;
}
try {
await adminApi.newsCategoryCreate({
name: catName,
sort: catSort,
isShow: 1,
});
setCatName('');
setCatSort(0);
await mutateCats();
} catch (e) {
alert((e as Error).message);
}
};
const onDeleteCategory = async (id: number) => {
if (!confirm('确认删除该分类?')) return;
try {
await adminApi.newsCategoryDelete(id);
await mutateCats();
} catch (e) {
alert((e as Error).message);
}
};
const columns: Column<News>[] = [
{ key: 'id', title: 'ID', width: 60 },
{
key: 'cover',
title: '封面',
width: 100,
render: (r) =>
r.cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveUploadUrl(r.cover)}
alt={r.title}
className="h-12 w-16 rounded object-cover"
/>
) : (
<span className="text-xs text-gray-400"></span>
),
},
{ key: 'title', title: '标题' },
{
key: 'category',
title: '分类',
width: 100,
render: (r) => r.category?.name ?? '-',
},
{
key: 'createdAt',
title: '发布时间',
width: 160,
render: (r) => formatDate(r.createdAt, 'YYYY-MM-DD HH:mm'),
},
{
key: 'isTop',
title: '置顶',
width: 80,
render: (r) =>
r.isTop === 1 ? <Badge variant="warning"></Badge> : '-',
},
{
key: 'status',
title: '状态',
width: 80,
render: (r) =>
r.status === 1 ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline">稿</Badge>
),
},
{
key: '_op',
title: '操作',
width: 150,
render: (r) => (
<div className="flex items-center gap-1.5 whitespace-nowrap">
<Button size="sm" variant="outline" onClick={() => openEdit(r.id)}>
<Pencil className="h-3 w-3" />
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
),
},
];
return (
<div>
<AdminHeader title="新闻管理" description="维护公司动态与行业资讯" />
<TableToolbar
left={
<>
<select
className="h-10 rounded-md border border-gray-300 px-3 text-sm"
value={categoryId ?? ''}
onChange={(e) => {
setCategoryId(e.target.value ? Number(e.target.value) : undefined);
setPage(1);
}}
>
<option value=""></option>
{(categories ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Input
placeholder="搜索标题"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchKw(keyword);
setPage(1);
}
}}
className="max-w-xs"
/>
<Button
variant="outline"
onClick={() => {
setSearchKw(keyword);
setPage(1);
}}
>
</Button>
</>
}
right={
<>
<Button variant="outline" onClick={() => setCatOpen(true)}>
<Tags className="h-4 w-4" />
</Button>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
</Button>
</>
}
/>
<PaginationTable<News>
columns={columns}
rows={data?.list ?? []}
total={data?.total ?? 0}
page={page}
pageSize={10}
loading={isLoading}
onPageChange={setPage}
rowKey={(r) => String(r.id)}
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{editId ? '编辑新闻' : '新增新闻'}</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> *</Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.categoryId}
onChange={(e) =>
setForm({ ...form, categoryId: Number(e.target.value) })
}
>
{(categories ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
/>
</div>
</div>
<div className="space-y-1.5">
<Label></Label>
<ImageUpload
value={form.cover}
onChange={(url) => setForm({ ...form, cover: url })}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Textarea
rows={2}
value={form.intro}
onChange={(e) => setForm({ ...form, intro: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label> *</Label>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-gray-200 p-0.5">
<button
type="button"
onClick={() => setContentMode('rich')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'rich'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
</button>
<button
type="button"
onClick={() => setContentMode('markdown')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'markdown'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
Markdown
</button>
</div>
{contentMode === 'markdown' && (
<MarkdownImport
onImport={(md) => setForm({ ...form, content: md })}
onError={(msg) => alert(msg)}
/>
)}
</div>
</div>
{contentMode === 'rich' ? (
<RichEditor
value={form.content}
onChange={(html) => setForm({ ...form, content: html })}
/>
) : (
<Textarea
rows={12}
placeholder="支持 Markdown 语法。可点击右上角上传 .md 文档"
className="font-mono text-sm"
value={form.content}
onChange={(e) =>
setForm({ ...form, content: e.target.value })
}
/>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.isTop}
onChange={(e) =>
setForm({ ...form, isTop: Number(e.target.value) })
}
>
<option value={0}></option>
<option value={1}></option>
</select>
</div>
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.status}
onChange={(e) =>
setForm({ ...form, status: Number(e.target.value) })
}
>
<option value={1}></option>
<option value={0}>稿</option>
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={catOpen} onOpenChange={setCatOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-2">
<Input
placeholder="新分类名称"
value={catName}
onChange={(e) => setCatName(e.target.value)}
/>
<Input
type="number"
placeholder="排序"
value={catSort}
onChange={(e) => setCatSort(Number(e.target.value))}
className="w-24"
/>
<Button onClick={onAddCategory}></Button>
</div>
<div className="max-h-80 overflow-y-auto">
{(categories ?? []).map((c) => (
<div
key={c.id}
className="flex items-center justify-between border-b border-gray-100 py-2"
>
<span className="text-sm text-gray-700">{c.name}</span>
<Button
size="sm"
variant="ghost"
onClick={() => onDeleteCategory(c.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{(categories ?? []).length === 0 && (
<p className="py-4 text-center text-sm text-gray-400">
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

5
app/admin/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function AdminIndexPage(): never {
redirect('/admin/dashboard');
}

516
app/admin/product/page.tsx Normal file
View File

@ -0,0 +1,516 @@
'use client';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { Plus, Pencil, Trash2, Tags, Loader2 } from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { PaginationTable, type Column } from '@/components/admin/PaginationTable';
import { TableToolbar } from '@/components/admin/TableToolbar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ImageUpload } from '@/components/admin/ImageUpload';
import { RichEditor } from '@/components/admin/RichEditor';
import { MarkdownImport } from '@/components/admin/MarkdownImport';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { cn, resolveUploadUrl } from '@/lib/utils';
import { isHTMLContent } from '@/lib/content';
import type { Product, ProductCategory } from '@/lib/types';
type ContentMode = 'rich' | 'markdown';
interface FormState {
categoryId: number;
name: string;
cover: string;
desc: string;
content: string;
sort: number;
isShow: number;
}
export default function ProductAdminPage() {
const [hydrated, setHydrated] = useState(false);
const [page, setPage] = useState(1);
const [categoryId, setCategoryId] = useState<number | undefined>();
const [keyword, setKeyword] = useState('');
const [searchKw, setSearchKw] = useState('');
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [contentMode, setContentMode] = useState<ContentMode>('rich');
const [form, setForm] = useState<FormState>({
categoryId: 0,
name: '',
cover: '',
desc: '',
content: '',
sort: 0,
isShow: 1,
});
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
// 分类管理弹窗
const [catOpen, setCatOpen] = useState(false);
const [catName, setCatName] = useState('');
const [catSort, setCatSort] = useState(0);
useEffect(() => setHydrated(true), []);
const { data: categories, mutate: mutateCats } = useSWR<ProductCategory[]>(
hydrated ? '/admin/product-category' : null,
() => adminApi.productCategoryAll(),
);
const { data, isLoading, mutate } = useSWR(
hydrated ? ['/admin/product', page, categoryId, searchKw] : null,
() =>
adminApi.productPaginate({
page,
pageSize: 10,
categoryId,
keyword: searchKw || undefined,
}),
);
const openCreate = () => {
if (!categories || categories.length === 0) {
alert('请先创建产品分类');
setCatOpen(true);
return;
}
setEditId(null);
setContentMode('rich');
setForm({
categoryId: categories[0].id,
name: '',
cover: '',
desc: '',
content: '',
sort: 0,
isShow: 1,
});
setOpen(true);
};
const openEdit = async (id: number) => {
const detail = await adminApi.productDetail(id);
setEditId(id);
setContentMode(isHTMLContent(detail.content) ? 'rich' : 'markdown');
setForm({
categoryId: detail.categoryId,
name: detail.name,
cover: detail.cover,
desc: detail.desc ?? '',
content: detail.content ?? '',
sort: detail.sort,
isShow: detail.isShow,
});
setOpen(true);
};
const onSave = async () => {
if (!form.categoryId) {
alert('请选择分类');
return;
}
if (!form.name || !form.cover) {
alert('请填写名称并上传封面');
return;
}
setSaving(true);
try {
const payload = { ...form, categoryId: Number(form.categoryId) };
if (editId) {
await adminApi.productUpdate(editId, payload);
} else {
await adminApi.productCreate(payload);
}
setOpen(false);
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
};
const onDelete = async (id: number) => {
if (!confirm('确认删除该产品?')) return;
try {
await adminApi.productDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
// 分类 CRUD
const onAddCategory = async () => {
if (!catName.trim()) {
alert('请输入分类名称');
return;
}
try {
await adminApi.productCategoryCreate({
name: catName,
sort: catSort,
isShow: 1,
});
setCatName('');
setCatSort(0);
await mutateCats();
} catch (e) {
alert((e as Error).message);
}
};
const onDeleteCategory = async (id: number) => {
if (!confirm('删除分类后,关联产品仍保留但分类无效。确认删除?')) return;
try {
await adminApi.productCategoryDelete(id);
await mutateCats();
} catch (e) {
alert((e as Error).message);
}
};
const columns: Column<Product>[] = [
{ key: 'id', title: 'ID', width: 60 },
{
key: 'cover',
title: '封面',
width: 100,
render: (r) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveUploadUrl(r.cover)}
alt={r.name}
className="h-12 w-16 rounded object-cover"
/>
),
},
{ key: 'name', title: '产品名称' },
{
key: 'category',
title: '分类',
width: 120,
render: (r) => r.category?.name ?? '-',
},
{
key: 'desc',
title: '描述',
render: (r) => (
<span className="line-clamp-1 max-w-md text-xs text-gray-600">
{r.desc ?? '-'}
</span>
),
},
{
key: 'isShow',
title: '状态',
width: 80,
render: (r) =>
r.isShow === 1 ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline"></Badge>
),
},
{
key: '_op',
title: '操作',
width: 150,
render: (r) => (
<div className="flex items-center gap-1.5 whitespace-nowrap">
<Button size="sm" variant="outline" onClick={() => openEdit(r.id)}>
<Pencil className="h-3 w-3" />
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
),
},
];
return (
<div>
<AdminHeader title="产品管理" description="维护产品信息,含分类管理" />
<TableToolbar
left={
<>
<select
className="h-10 rounded-md border border-gray-300 px-3 text-sm"
value={categoryId ?? ''}
onChange={(e) => {
setCategoryId(e.target.value ? Number(e.target.value) : undefined);
setPage(1);
}}
>
<option value=""></option>
{(categories ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Input
placeholder="搜索产品名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchKw(keyword);
setPage(1);
}
}}
className="max-w-xs"
/>
<Button
variant="outline"
onClick={() => {
setSearchKw(keyword);
setPage(1);
}}
>
</Button>
</>
}
right={
<>
<Button variant="outline" onClick={() => setCatOpen(true)}>
<Tags className="h-4 w-4" />
</Button>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
</Button>
</>
}
/>
<PaginationTable<Product>
columns={columns}
rows={data?.list ?? []}
total={data?.total ?? 0}
page={page}
pageSize={10}
loading={isLoading}
onPageChange={setPage}
rowKey={(r) => String(r.id)}
/>
{/* 产品表单 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{editId ? '编辑产品' : '新增产品'}</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> *</Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.categoryId}
onChange={(e) =>
setForm({ ...form, categoryId: Number(e.target.value) })
}
>
{(categories ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<ImageUpload
value={form.cover}
onChange={(url) => setForm({ ...form, cover: url })}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Textarea
rows={3}
value={form.desc}
onChange={(e) => setForm({ ...form, desc: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label></Label>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-gray-200 p-0.5">
<button
type="button"
onClick={() => setContentMode('rich')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'rich'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
</button>
<button
type="button"
onClick={() => setContentMode('markdown')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'markdown'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
Markdown
</button>
</div>
{contentMode === 'markdown' && (
<MarkdownImport
onImport={(md) => setForm({ ...form, content: md })}
onError={(msg) => alert(msg)}
/>
)}
</div>
</div>
{contentMode === 'rich' ? (
<RichEditor
value={form.content}
onChange={(html) => setForm({ ...form, content: html })}
/>
) : (
<Textarea
rows={12}
placeholder="支持 Markdown 语法。可点击右上角上传 .md 文档"
className="font-mono text-sm"
value={form.content}
onChange={(e) =>
setForm({ ...form, content: e.target.value })
}
/>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) =>
setForm({ ...form, sort: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.isShow}
onChange={(e) =>
setForm({ ...form, isShow: Number(e.target.value) })
}
>
<option value={1}></option>
<option value={0}></option>
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分类管理 */}
<Dialog open={catOpen} onOpenChange={setCatOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-2">
<Input
placeholder="新分类名称"
value={catName}
onChange={(e) => setCatName(e.target.value)}
/>
<Input
type="number"
placeholder="排序"
value={catSort}
onChange={(e) => setCatSort(Number(e.target.value))}
className="w-24"
/>
<Button onClick={onAddCategory}></Button>
</div>
<div className="max-h-80 overflow-y-auto">
{(categories ?? []).map((c) => (
<div
key={c.id}
className="flex items-center justify-between border-b border-gray-100 py-2"
>
<span className="text-sm text-gray-700">{c.name}</span>
<Button
size="sm"
variant="ghost"
onClick={() => onDeleteCategory(c.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{(categories ?? []).length === 0 && (
<p className="py-4 text-center text-sm text-gray-400">
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,317 @@
'use client';
import { useEffect, useState } from 'react';
import {
Building2,
Phone,
Mail,
MapPin,
ShieldCheck,
FileText,
Loader2,
} from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { ImageUpload } from '@/components/admin/ImageUpload';
import { RichEditor } from '@/components/admin/RichEditor';
import { adminApi } from '@/lib/admin-services';
import type { SiteConfig } from '@/lib/types';
interface FormState {
siteName: string;
logo: string;
tel: string;
address: string;
email: string;
copyright: string;
icp: string;
aboutTitle: string;
aboutContent: string;
}
const DEFAULT: FormState = {
siteName: '',
logo: '',
tel: '',
address: '',
email: '',
copyright: '',
icp: '',
aboutTitle: '',
aboutContent: '',
};
function SectionHead({
icon,
title,
desc,
}: {
icon: React.ReactNode;
title: string;
desc: string;
}) {
return (
<div className="mb-5 flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-slate-800 to-slate-900 text-white shadow-md shadow-slate-900/20 ring-1 ring-inset ring-white/10">
{icon}
</span>
<div>
<div className="text-sm font-semibold text-slate-900">{title}</div>
<div className="text-xs text-slate-400">{desc}</div>
</div>
</div>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-600">
{label}
{required && <span className="ml-0.5 text-brand-600">*</span>}
</Label>
{children}
</div>
);
}
export default function SiteConfigAdminPage() {
const [hydrated, setHydrated] = useState(false);
const [form, setForm] = useState<FormState>(DEFAULT);
const [saving, setSaving] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => setHydrated(true), []);
useEffect(() => {
if (!hydrated) return;
adminApi
.siteConfigGet()
.then((c: SiteConfig) => {
setForm({
siteName: c.siteName,
logo: c.logo,
tel: c.tel,
address: c.address,
email: c.email,
copyright: c.copyright,
icp: c.icp,
aboutTitle: c.aboutTitle,
aboutContent: c.aboutContent ?? '',
});
setLoaded(true);
})
.catch((e) => alert((e as Error).message));
}, [hydrated]);
const onSave = async () => {
if (!form.siteName) {
alert('请填写网站名称');
return;
}
setSaving(true);
try {
await adminApi.siteConfigUpdate(form);
alert('保存成功');
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
};
if (!loaded) {
return (
<div className="flex h-40 items-center justify-center text-sm text-slate-400">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
</div>
);
}
const cardCls =
'border-slate-200/70 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.04),0_8px_24px_-12px_rgba(79,70,229,0.12)]';
return (
<div>
<AdminHeader
title="网站配置"
description="管理全站基础信息、联系方式、底部版权与企业简介"
actions={
<Button onClick={onSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
</>
) : (
'保存配置'
)}
</Button>
}
/>
<div className="grid gap-5 lg:grid-cols-2">
{/* ===== 品牌标识 ===== */}
<Card className={cardCls}>
<CardContent className="p-6">
<SectionHead
icon={<Building2 className="h-4 w-4" />}
title="品牌标识"
desc="站点名称与 Logo"
/>
<div className="space-y-4">
<Field label="网站名称" required>
<Input
className="h-10"
value={form.siteName}
onChange={(e) =>
setForm({ ...form, siteName: e.target.value })
}
placeholder="如:智管物业"
/>
</Field>
<Field label="Logo 图片">
<ImageUpload
value={form.logo}
onChange={(url) => setForm({ ...form, logo: url })}
hint="透明背景 PNG 最佳,建议 200x48"
/>
</Field>
</div>
</CardContent>
</Card>
{/* ===== 联系方式 ===== */}
<Card className={cardCls}>
<CardContent className="p-6">
<SectionHead
icon={<Phone className="h-4 w-4" />}
title="联系方式"
desc="客户咨询与对外联络"
/>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="联系电话">
<div className="relative">
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="h-10 pl-9"
value={form.tel}
onChange={(e) =>
setForm({ ...form, tel: e.target.value })
}
placeholder="400-000-0000"
/>
</div>
</Field>
<Field label="商务邮箱">
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="h-10 pl-9"
value={form.email}
onChange={(e) =>
setForm({ ...form, email: e.target.value })
}
placeholder="business@example.com"
/>
</div>
</Field>
</div>
<Field label="公司地址">
<div className="relative">
<MapPin className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="h-10 pl-9"
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
placeholder="省市区详细地址"
/>
</div>
</Field>
</div>
</CardContent>
</Card>
{/* ===== 版权与备案 ===== */}
<Card className={cardCls}>
<CardContent className="p-6">
<SectionHead
icon={<ShieldCheck className="h-4 w-4" />}
title="版权与备案"
desc="底部声明与合规信息"
/>
<div className="space-y-4">
<Field label="底部版权">
<Textarea
rows={2}
className="resize-none"
value={form.copyright}
onChange={(e) =>
setForm({ ...form, copyright: e.target.value })
}
placeholder="© 2026 企业名称 版权所有"
/>
</Field>
<Field label="备案号">
<Input
className="h-10"
value={form.icp}
onChange={(e) => setForm({ ...form, icp: e.target.value })}
placeholder="如京ICP备 00000000 号"
/>
</Field>
</div>
</CardContent>
</Card>
{/* ===== 企业简介 ===== */}
<Card className={`${cardCls} lg:col-span-2`}>
<CardContent className="p-6">
<SectionHead
icon={<FileText className="h-4 w-4" />}
title="企业简介"
desc="关于我们页面的标题与正文"
/>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="简介标题">
<Input
className="h-10"
value={form.aboutTitle}
onChange={(e) =>
setForm({ ...form, aboutTitle: e.target.value })
}
placeholder="如:关于我们"
/>
</Field>
</div>
<Field label="简介正文(富文本)">
<RichEditor
value={form.aboutContent}
onChange={(html) =>
setForm({ ...form, aboutContent: html })
}
/>
</Field>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

336
app/admin/team/page.tsx Normal file
View File

@ -0,0 +1,336 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { PaginationTable, type Column } from '@/components/admin/PaginationTable';
import { TableToolbar } from '@/components/admin/TableToolbar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ImageUpload } from '@/components/admin/ImageUpload';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { resolveUploadUrl } from '@/lib/utils';
import type { Team } from '@/lib/types';
interface FormState {
name: string;
position: string;
avatar: string;
desc: string;
sort: number;
isShow: number;
}
const DEFAULT: FormState = {
name: '',
position: '',
avatar: '',
desc: '',
sort: 0,
isShow: 1,
};
export default function TeamAdminPage() {
const [hydrated, setHydrated] = useState(false);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const [searchKw, setSearchKw] = useState('');
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(DEFAULT);
const [saving, setSaving] = useState(false);
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
const PAGE_SIZE = 10;
useEffect(() => setHydrated(true), []);
const { data, isLoading, mutate } = useSWR<Team[]>(
hydrated ? '/admin/team' : null,
() => adminApi.teamAll(),
);
// 防御性归一化:避免缓存/接口异常导致 data 不是数组
const list = Array.isArray(data) ? data : [];
// 客户端搜索过滤:按姓名或职位模糊匹配
const filtered = useMemo(() => {
const kw = searchKw.trim().toLowerCase();
if (!kw) return list;
return list.filter(
(m) =>
m.name.toLowerCase().includes(kw) ||
m.position.toLowerCase().includes(kw),
);
}, [list, searchKw]);
// 客户端分页
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pagedRows = useMemo(
() => filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE),
[filtered, safePage],
);
// 搜索/过滤变化时,若超出范围自动回到第 1 页
useEffect(() => {
if (page > totalPages) setPage(1);
}, [page, totalPages]);
const openCreate = () => {
setEditId(null);
setForm(DEFAULT);
setOpen(true);
};
const openEdit = async (id: number) => {
const d = await adminApi.teamDetail(id);
setEditId(id);
setForm({
name: d.name,
position: d.position,
avatar: d.avatar,
desc: d.desc ?? '',
sort: d.sort,
isShow: d.isShow,
});
setOpen(true);
};
const onSave = async () => {
if (!form.name || !form.position || !form.avatar) {
alert('请填写姓名、职位并上传头像');
return;
}
setSaving(true);
try {
if (editId) {
await adminApi.teamUpdate(editId, form);
} else {
await adminApi.teamCreate(form);
}
setOpen(false);
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
};
const onDelete = async (id: number) => {
if (!confirm('确认删除该成员?')) return;
try {
await adminApi.teamDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
const columns: Column<Team>[] = [
{ key: 'id', title: 'ID', width: 60 },
{
key: 'avatar',
title: '头像',
width: 80,
render: (r) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveUploadUrl(r.avatar)}
alt={r.name}
className="h-10 w-10 rounded-full object-cover"
/>
),
},
{ key: 'name', title: '姓名', width: 120 },
{ key: 'position', title: '职位', width: 160 },
{
key: 'desc',
title: '简介',
render: (r) => (
<span className="line-clamp-2 max-w-md text-xs text-gray-600">
{r.desc ?? '-'}
</span>
),
},
{ key: 'sort', title: '排序', width: 80 },
{
key: 'isShow',
title: '状态',
width: 90,
render: (r) =>
r.isShow === 1 ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline"></Badge>
),
},
{
key: '_op',
title: '操作',
width: 150,
render: (r) => (
<div className="flex items-center gap-1.5 whitespace-nowrap">
<Button size="sm" variant="outline" onClick={() => openEdit(r.id)}>
<Pencil className="h-3 w-3" />
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
),
},
];
return (
<div>
<AdminHeader title="团队管理" description="维护团队成员信息" />
<TableToolbar
left={
<>
<Input
placeholder="搜索姓名/职位"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchKw(keyword);
setPage(1);
}
}}
className="max-w-xs"
/>
<Button
variant="outline"
onClick={() => {
setSearchKw(keyword);
setPage(1);
}}
>
</Button>
</>
}
right={
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
</Button>
}
/>
<PaginationTable<Team>
columns={columns}
rows={pagedRows}
total={total}
page={safePage}
pageSize={PAGE_SIZE}
loading={isLoading}
onPageChange={setPage}
rowKey={(r) => String(r.id)}
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editId ? '编辑成员' : '新增成员'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.position}
onChange={(e) => setForm({ ...form, position: e.target.value })}
/>
</div>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<ImageUpload
value={form.avatar}
onChange={(url) => setForm({ ...form, avatar: url })}
hint="建议正方形头像,单图 ≤ 2M"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Textarea
rows={3}
value={form.desc}
onChange={(e) => setForm({ ...form, desc: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) =>
setForm({ ...form, sort: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.isShow}
onChange={(e) =>
setForm({ ...form, isShow: Number(e.target.value) })
}
>
<option value={1}></option>
<option value={0}></option>
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

116
app/globals.css Normal file
View File

@ -0,0 +1,116 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-background text-foreground antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
}
@layer components {
.container-page {
@apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8;
}
.prose-rich h1,
.prose-rich h2,
.prose-rich h3 {
@apply mt-6 mb-3 font-semibold text-gray-900;
}
.prose-rich h1 {
@apply text-2xl;
}
.prose-rich h2 {
@apply text-xl;
}
.prose-rich h3 {
@apply text-lg;
}
.prose-rich p {
@apply my-3 leading-7 text-gray-700;
}
.prose-rich img {
@apply my-4 rounded-lg;
}
.prose-rich ul,
.prose-rich ol {
@apply my-3 pl-6;
}
.prose-rich ul {
@apply list-disc;
}
.prose-rich ol {
@apply list-decimal;
}
.prose-rich a {
@apply text-brand-600 underline underline-offset-2;
}
.prose-rich blockquote {
@apply border-l-4 border-gray-200 pl-4 italic text-gray-600;
}
}
/* react-quill 编辑器内部样式 */
.ql-editor {
min-height: 240px;
}
/* ===== Hero 几何动画 ===== */
@keyframes hero-float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-20px) rotate(6deg); }
}
@keyframes hero-float-rev {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(16px) rotate(-8deg); }
}
@keyframes hero-spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes hero-pulse-ring {
0% { transform: scale(0.8); opacity: 0.6; }
50% { transform: scale(1.1); opacity: 0.2; }
100% { transform: scale(0.8); opacity: 0.6; }
}
@keyframes hero-slide {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(12px); }
}
.hero-anim-float { animation: hero-float 6s ease-in-out infinite; }
.hero-anim-float-rev { animation: hero-float-rev 7s ease-in-out infinite; }
.hero-anim-spin { animation: hero-spin-slow 18s linear infinite; }
.hero-anim-pulse { animation: hero-pulse-ring 4s ease-in-out infinite; }
.hero-anim-slide { animation: hero-slide 5s ease-in-out infinite; }
/* ===== Showcase 浮动动画 ===== */
@keyframes showcase-gentle-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes showcase-glow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
.showcase-anim-float { animation: showcase-gentle-float 5s ease-in-out infinite; }
.showcase-anim-glow { animation: showcase-glow 4s ease-in-out infinite; }

42
app/layout.tsx Normal file
View File

@ -0,0 +1,42 @@
import type { Metadata, Viewport } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: {
default: '智管物业 - 物业管理SaaS专家',
template: '%s - 智管物业',
},
description:
'智管物业 - 让物业管理像发微信一样简单。提供物业缴费、报修管理、社区公告、业主服务等一站式物业管理小程序/SaaS解决方案。',
keywords: [
'物业管理软件',
'物业小程序',
'物业缴费系统',
'物业报修',
'社区管理',
'智慧物业',
'物业SaaS',
'业主服务',
],
icons: {
icon: '/favicon.ico',
},
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#312e81',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}

View File

@ -0,0 +1,57 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useAdminStore } from '@/store/adminStore';
import { tokenStorage } from '@/lib/api';
import { Loader2 } from 'lucide-react';
interface AdminHeaderProps {
title: string;
description?: string;
actions?: React.ReactNode;
}
export function AdminHeader({ title, description, actions }: AdminHeaderProps) {
const router = useRouter();
const token = useAdminStore((s) => s.token);
const [checking, setChecking] = useState(true);
useEffect(() => {
// 客户端兜底:如果 store 中没 token 但 localStorage 有,刷新一下
const lsToken = tokenStorage.get();
if (!token && lsToken) {
useAdminStore.setState({
token: lsToken,
admin: useAdminStore.getState().admin,
});
}
setChecking(false);
}, [token]);
useEffect(() => {
if (!checking && !token) {
router.replace('/admin/login');
}
}, [checking, token, router]);
if (checking || !token) {
return (
<div className="flex h-40 items-center justify-center text-gray-400">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
</div>
);
}
return (
<header className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
{description && (
<p className="mt-1 text-sm text-gray-500">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</header>
);
}

View File

@ -0,0 +1,31 @@
'use client';
import { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { AdminSidebar } from './AdminSidebar';
import { TopBar } from './TopBar';
/**
* Admin
* /admin/* TopBar +
*/
export function AdminShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
// 登录页(以及其它无侧边栏的独立页)直接渲染内容
if (pathname === '/admin/login') {
return <>{children}</>;
}
return (
<div className="flex min-h-screen flex-col bg-gray-50">
<TopBar />
<div className="flex flex-1">
<AdminSidebar />
<main className="flex-1 overflow-x-hidden">
<div className="min-h-screen p-6 lg:p-8">{children}</div>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Package,
Newspaper,
Users,
MessageSquare,
Settings,
UserCog,
BookOpen,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAdminStore } from '@/store/adminStore';
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
/** 仅超级管理员可见 */
superAdminOnly?: boolean;
}
interface NavGroup {
label: string;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
label: '概览',
items: [
{ href: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
],
},
{
label: '内容管理',
items: [
{ href: '/admin/product', label: '产品管理', icon: Package },
{ href: '/admin/news', label: '新闻管理', icon: Newspaper },
{ href: '/admin/team', label: '团队管理', icon: Users },
{ href: '/admin/message', label: '留言管理', icon: MessageSquare },
{ href: '/admin/manual', label: '使用手册', icon: BookOpen },
],
},
{
label: '系统',
items: [
{ href: '/admin/site-config', label: '网站配置', icon: Settings },
{
href: '/admin/admin-user',
label: '管理员账号',
icon: UserCog,
superAdminOnly: true,
},
],
},
];
export function AdminSidebar() {
const pathname = usePathname();
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
return (
<aside className="flex h-screen w-60 flex-col bg-slate-900 text-slate-200">
<nav className="flex-1 overflow-y-auto px-2 py-2">
{NAV_GROUPS.map((group, gIdx) => {
const visibleItems = group.items.filter(
(item) => !item.superAdminOnly || isSuperAdmin,
);
if (visibleItems.length === 0) return null;
return (
<div key={group.label} className={gIdx === 0 ? '' : 'mt-4'}>
<p className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
{group.label}
</p>
<div className="space-y-0.5">
{visibleItems.map((item) => {
const active =
pathname === item.href ||
pathname.startsWith(item.href + '/');
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'relative flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors duration-150',
active
? 'bg-slate-800/60 text-white'
: 'text-slate-300 hover:bg-slate-800/40 hover:text-white',
)}
>
{active && (
<span className="absolute bottom-2 left-0 top-2 w-[3px] rounded-r bg-brand-500" />
)}
<Icon className="h-[18px] w-[18px] shrink-0" />
{item.label}
</Link>
);
})}
</div>
</div>
);
})}
</nav>
</aside>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import { useState } from 'react';
import { cn, resolveUploadUrl } from '@/lib/utils';
interface AvatarProps {
src?: string | null;
name?: string | null;
size?: number;
className?: string;
}
/**
* src onError
*/
export function Avatar({ src, name, size = 32, className }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const showImg = Boolean(src) && !imgError;
const initial = (name?.trim()?.charAt(0) ?? '?').toUpperCase();
const fontSize = Math.max(12, Math.round(size * 0.45));
if (showImg) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveUploadUrl(src ?? '')}
alt={name ?? 'avatar'}
onError={() => setImgError(true)}
style={{ width: size, height: size }}
className={cn('rounded-full object-cover', className)}
/>
);
}
return (
<div
style={{ width: size, height: size, fontSize }}
className={cn(
'flex shrink-0 items-center justify-center rounded-full bg-brand-600 font-medium text-white',
className,
)}
>
{initial}
</div>
);
}

View File

@ -0,0 +1,127 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import { ImagePlus, Loader2, X } from 'lucide-react';
import { http } from '@/lib/api';
import { resolveUploadUrl, cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
export interface ImageUploadProps {
value?: string;
onChange: (url: string) => void;
className?: string;
/** 提示文字 */
hint?: string;
}
interface UploadResp {
url: string;
filename: string;
}
/**
*
* - jpg/png/jpeg/webp
* - 2M
* - POST /api/admin/upload
*/
export function ImageUpload({
value,
onChange,
className,
hint = '建议尺寸 1600x900支持 jpg/png/webp单图 ≤ 2M',
}: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = useCallback(
async (file: File) => {
const allowed = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp'];
if (!allowed.includes(file.type)) {
setError('仅支持 jpg/png/jpeg/webp 格式');
return;
}
if (file.size > 2 * 1024 * 1024) {
setError('单图大小不能超过 2M');
return;
}
setError(null);
setUploading(true);
try {
const form = new FormData();
form.append('file', file);
const res = (await http.post<unknown, UploadResp>(
'/admin/upload',
form,
{ headers: { 'Content-Type': 'multipart/form-data' } },
)) as UploadResp;
onChange(res.url);
} catch (e) {
setError((e as Error).message);
} finally {
setUploading(false);
}
},
[onChange],
);
const onPick = () => inputRef.current?.click();
const preview = value ? resolveUploadUrl(value) : '';
return (
<div className={cn('flex items-start gap-4', className)}>
<div
className={cn(
'relative flex h-28 w-44 items-center justify-center overflow-hidden rounded-md border border-dashed border-gray-300 bg-gray-50',
!preview && 'cursor-pointer hover:border-brand-400',
)}
onClick={!preview ? onPick : undefined}
>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="预览" className="h-full w-full object-cover" />
) : uploading ? (
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
) : (
<div className="flex flex-col items-center text-gray-400">
<ImagePlus className="h-6 w-6" />
<span className="mt-1 text-xs"></span>
</div>
)}
{preview && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange('');
}}
className="absolute right-1 top-1 rounded-full bg-black/60 p-1 text-white hover:bg-black/80"
aria-label="移除"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<div className="flex-1">
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
e.target.value = '';
}}
/>
<Button type="button" variant="outline" size="sm" onClick={onPick} disabled={uploading}>
{uploading ? '上传中…' : '选择图片'}
</Button>
<p className="mt-2 text-xs text-gray-500">{hint}</p>
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useRef, useState } from 'react';
import { FileUp, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface MarkdownImportProps {
/** 上传成功后回调,传入 MD 原文和文件名 */
onImport: (markdown: string, filename: string) => void;
/** 错误回调 */
onError?: (msg: string) => void;
/** 单文件大小上限(字节),默认 2MB */
maxSize?: number;
}
/**
* Markdown
* - .md / .markdown
* - onImport
*/
export function MarkdownImport({
onImport,
onError,
maxSize = 2 * 1024 * 1024,
}: MarkdownImportProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const handleFile = async (file: File) => {
const isMd =
/\.md$/i.test(file.name) ||
/\.markdown$/i.test(file.name) ||
file.type === 'text/markdown';
if (!isMd) {
onError?.('请选择 .md 或 .markdown 文件');
return;
}
if (file.size > maxSize) {
onError?.('文件不能超过 2M');
return;
}
setLoading(true);
try {
const text = await file.text();
onImport(text, file.name);
} catch (e) {
onError?.((e as Error).message);
} finally {
setLoading(false);
}
};
return (
<>
<input
ref={inputRef}
type="file"
accept=".md,.markdown,text/markdown"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
e.target.value = '';
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => inputRef.current?.click()}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileUp className="h-4 w-4" />
)}
Markdown
</Button>
</>
);
}

View File

@ -0,0 +1,133 @@
'use client';
import { ReactNode, useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface Column<T> {
key: string;
title: string;
width?: number | string;
render?: (row: T, index: number) => ReactNode;
className?: string;
}
export interface PaginationTableProps<T> {
columns: Column<T>[];
rows: T[];
total: number;
page: number;
pageSize: number;
loading?: boolean;
onPageChange: (page: number) => void;
rowKey?: (row: T, index: number) => string;
emptyText?: string;
className?: string;
}
export function PaginationTable<T>({
columns,
rows,
total,
page,
pageSize,
loading,
onPageChange,
rowKey,
emptyText = '暂无数据',
className,
}: PaginationTableProps<T>) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const [cur, setCur] = useState(page);
useEffect(() => setCur(page), [page]);
const go = (p: number) => {
const target = Math.min(Math.max(1, p), totalPages);
setCur(target);
onPageChange(target);
};
const start = total === 0 ? 0 : (cur - 1) * pageSize + 1;
const end = Math.min(cur * pageSize, total);
return (
<div className={cn('w-full', className)}>
<div className="rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
{columns.map((c) => (
<TableHead key={c.key} style={{ width: c.width }} className={c.className}>
{c.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-32">
<div className="flex items-center justify-center gap-2 text-gray-400">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm"></span>
</div>
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center text-gray-400">
{emptyText}
</TableCell>
</TableRow>
) : (
rows.map((row, idx) => (
<TableRow key={rowKey ? rowKey(row, idx) : String(idx)}>
{columns.map((c) => (
<TableCell key={c.key} className={c.className}>
{c.render ? c.render(row, idx) : String((row as Record<string, unknown>)[c.key] ?? '')}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-3 flex flex-col items-center justify-between gap-2 sm:flex-row">
<p className="text-xs text-gray-500">
{total} {start}-{end}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={cur <= 1}
onClick={() => go(cur - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-gray-600">
{cur} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={cur >= totalPages}
onClick={() => go(cur + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
'use client';
import dynamic from 'next/dynamic';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
// quill 主题样式client 组件可直接 import
import 'react-quill/dist/quill.snow.css';
// react-quill 仅支持客户端渲染
const ReactQuill = dynamic(() => import('react-quill'), {
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center rounded bg-gray-50 text-xs text-gray-400">
</div>
),
});
export interface RichEditorProps {
value?: string;
onChange: (html: string) => void;
placeholder?: string;
className?: string;
}
const TOOLBAR = [
[{ header: [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ align: [] }, { list: 'ordered' }, { list: 'bullet' }],
['blockquote', 'code-block'],
['link', 'image'],
['clean'],
];
// 图片插入:转 base64 内联(如需对接后端上传,可在此处替换实现)
// quill 的 toolbar handler this 指向 toolbar 实例,需要 any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function imageHandler(this: any): void {
const quill = this?.quill;
if (!quill) return;
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const range = quill.getSelection();
const idx = range ? range.index : 0;
quill.insertEmbed(idx, 'image', String(reader.result));
};
reader.readAsDataURL(file);
});
input.click();
}
export function RichEditor({
value,
onChange,
placeholder = '请输入内容…',
className,
}: RichEditorProps) {
const modules = useMemo(
() => ({
toolbar: {
container: TOOLBAR,
handlers: { image: imageHandler },
},
}),
[],
);
return (
<div
className={cn(
'overflow-hidden rounded-md border border-gray-300 bg-white',
className,
)}
>
<ReactQuill
theme="snow"
value={value ?? ''}
onChange={onChange}
modules={modules}
placeholder={placeholder}
/>
</div>
);
}

View File

@ -0,0 +1,31 @@
'use client';
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface TableToolbarProps {
/** 左侧:搜索/筛选区 */
left?: ReactNode;
/** 右侧:操作按钮区 */
right?: ReactNode;
className?: string;
}
/**
* +
*
* flex-wrap
*/
export function TableToolbar({ left, right, className }: TableToolbarProps) {
return (
<div
className={cn(
'mb-4 flex flex-wrap items-center justify-between gap-2',
className,
)}
>
<div className="flex items-center gap-2 whitespace-nowrap">{left}</div>
{right && <div className="flex items-center gap-2 whitespace-nowrap">{right}</div>}
</div>
);
}

115
components/admin/TopBar.tsx Normal file
View File

@ -0,0 +1,115 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Diamond, ChevronDown, LogOut, Crown } from 'lucide-react';
import { useAdminStore } from '@/store/adminStore';
import { Badge } from '@/components/ui/badge';
import { Avatar } from './Avatar';
/**
* + + 退
* /admin/* /admin/login
*/
export function TopBar() {
const router = useRouter();
const admin = useAdminStore((s) => s.admin);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// 下拉打开时:监听外部点击 + Esc 键关闭
useEffect(() => {
if (!menuOpen) return;
const onMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false);
};
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [menuOpen]);
const handleLogout = () => {
setMenuOpen(false);
useAdminStore.getState().logout();
router.replace('/admin/login');
};
const nickname = admin?.nickname ?? admin?.username ?? '管理员';
const username = admin?.username ?? 'admin';
const isSuperAdmin = admin?.role === 'super_admin';
return (
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-gray-200 bg-white/95 px-6 backdrop-blur">
<div className="flex items-center gap-2">
<Diamond className="h-6 w-6 text-brand-600" />
<span className="text-base font-semibold text-gray-900">
</span>
</div>
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-gray-100"
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<Avatar src={admin?.avatar} name={nickname} size={32} />
<span className="text-sm font-medium text-gray-700">{nickname}</span>
{isSuperAdmin && (
<Badge variant="warning">
<Crown className="h-3 w-3" />
</Badge>
)}
<ChevronDown className="h-4 w-4 text-gray-500" />
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 top-full mt-2 w-60 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg"
>
<div className="border-b border-gray-100 px-4 py-3">
<div className="flex items-center gap-3">
<Avatar src={admin?.avatar} name={nickname} size={40} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900">
{nickname}
</p>
<p className="truncate text-xs text-gray-500">@{username}</p>
</div>
</div>
<div className="mt-2">
{isSuperAdmin ? (
<Badge variant="warning">
<Crown className="h-3 w-3" />
</Badge>
) : (
<Badge variant="secondary"></Badge>
)}
</div>
</div>
<button
type="button"
role="menuitem"
onClick={handleLogout}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-red-600 transition-colors hover:bg-red-50"
>
<LogOut className="h-4 w-4" />
退
</button>
</div>
)}
</div>
</header>
);
}

View File

@ -0,0 +1,121 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import type { Banner } from '@/lib/types';
import { resolveUploadUrl, cn } from '@/lib/utils';
const INTERVAL = 5000;
export function BannerCarousel({ banners }: { banners: Banner[] }) {
const [idx, setIdx] = useState(0);
const total = banners.length;
const next = useCallback(() => {
setIdx((p) => (total === 0 ? 0 : (p + 1) % total));
}, [total]);
useEffect(() => {
if (total <= 1) return;
const t = setInterval(next, INTERVAL);
return () => clearInterval(t);
}, [next, total]);
if (total === 0) {
return <HeroPlaceholder />;
}
return (
<div className="relative h-[420px] overflow-hidden bg-gray-100">
{banners.map((b, i) => (
<div
key={b.id}
className={cn(
'absolute inset-0 transition-opacity duration-700',
i === idx ? 'opacity-100' : 'opacity-0',
)}
>
{b.link ? (
<Link href={b.link} target="_blank" rel="noreferrer" className="block h-full w-full">
<BG image={b.image} title={b.title} />
</Link>
) : (
<BG image={b.image} title={b.title} />
)}
</div>
))}
{total > 1 && (
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
{banners.map((_, i) => (
<button
key={i}
aria-label={`${i + 1}`}
onClick={() => setIdx(i)}
className={cn(
'h-2 rounded-full transition-all',
i === idx ? 'w-6 bg-white' : 'w-2 bg-white/50',
)}
/>
))}
</div>
)}
</div>
);
}
function BG({ image, title }: { image: string; title: string }) {
return (
<div
className="relative h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${resolveUploadUrl(image)})` }}
>
<div className="absolute inset-0 bg-black/40" />
<div className="container-page flex h-full items-center">
<h2 className="max-w-xl text-2xl font-bold leading-snug text-white sm:text-3xl md:text-4xl">
{title}
</h2>
</div>
</div>
);
}
function HeroPlaceholder() {
return (
<div className="relative flex h-[480px] items-center overflow-hidden bg-gradient-to-br from-brand-700 via-brand-600 to-brand-800">
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
'radial-gradient(circle at 20% 50%, rgba(255,255,255,0.15) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255,255,255,0.1) 0%, transparent 40%)',
}}
/>
<div className="container-page relative">
<span className="inline-block rounded-full bg-white/15 px-4 py-1.5 text-sm text-white backdrop-blur">
· · SaaS
</span>
<h1 className="mt-6 max-w-2xl text-4xl font-bold leading-tight text-white sm:text-5xl md:text-6xl">
<br />
</h1>
<p className="mt-4 max-w-xl text-lg text-brand-100">
&ldquo;&rdquo;
</p>
<div className="mt-8 flex flex-col gap-4 sm:flex-row">
<Link
href="/contact"
className="rounded-lg bg-white px-7 py-3 text-sm font-semibold text-brand-700 shadow-lg transition-colors hover:bg-brand-50"
>
</Link>
<Link
href="/products"
className="rounded-lg border border-white/40 px-7 py-3 text-sm font-medium text-white transition-colors hover:bg-white/10"
>
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,158 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { publicApi } from '@/lib/services';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
const ERROR_INPUT_CLASS = 'border-red-500 focus-visible:ring-red-500';
const schema = z.object({
name: z
.string()
.trim()
.min(1, '请输入姓名')
.max(50, '姓名不能超过 50 个字'),
phone: z
.string()
.trim()
.min(1, '请输入手机号')
.regex(/^1[3-9]\d{9}$/, '请输入正确的手机号11 位)'),
email: z
.string()
.trim()
.max(254, '邮箱长度不能超过 254')
.email('邮箱格式不正确')
.optional()
.or(z.literal('')),
content: z
.string()
.trim()
.min(5, '留言内容至少 5 个字')
.max(2000, '留言内容不能超过 2000 个字'),
});
type FormData = z.infer<typeof schema>;
export function ContactForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onTouched',
reValidateMode: 'onChange',
defaultValues: { name: '', phone: '', email: '', content: '' },
});
const [serverError, setServerError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const onSubmit = async (data: FormData) => {
setServerError(null);
setSuccess(false);
try {
await publicApi.submitMessage({
name: data.name,
phone: data.phone,
email: data.email || undefined,
content: data.content,
});
setSuccess(true);
reset();
} catch (e) {
setServerError((e as Error).message);
}
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit(onSubmit)(e);
}}
className="space-y-4"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="姓名" error={errors.name?.message} required>
<Input
placeholder="请输入您的姓名"
autoComplete="name"
maxLength={50}
className={errors.name ? ERROR_INPUT_CLASS : ''}
{...register('name')}
/>
</Field>
<Field label="手机号" error={errors.phone?.message} required>
<Input
placeholder="请输入 11 位手机号"
inputMode="numeric"
autoComplete="tel"
maxLength={11}
className={errors.phone ? ERROR_INPUT_CLASS : ''}
{...register('phone')}
/>
</Field>
</div>
<Field label="邮箱(可选)" error={errors.email?.message}>
<Input
placeholder="example@company.com"
inputMode="email"
autoComplete="email"
maxLength={254}
className={errors.email ? ERROR_INPUT_CLASS : ''}
{...register('email')}
/>
</Field>
<Field label="留言内容" error={errors.content?.message} required>
<Textarea
rows={5}
placeholder="请简要描述您的需求(至少 5 个字)"
maxLength={2000}
className={errors.content ? ERROR_INPUT_CLASS : ''}
{...register('content')}
/>
</Field>
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
{success && (
<p className="text-sm text-green-600">
</p>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中…' : '提交留言'}
</Button>
</form>
);
}
function Field({
label,
required,
error,
children,
}: {
label: string;
required?: boolean;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label>
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</Label>
{children}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import ReactMarkdown, { type Components } from 'react-markdown';
import { isHTMLContent } from '@/lib/content';
interface ContentViewProps {
content: string | null | undefined;
className?: string;
/**
*
* - 'html'dangerouslySetInnerHTML
* - 'markdown' react-markdown
* 退 isHTMLContent
*/
format?: 'html' | 'markdown';
}
const mdComponents: Components = {
pre: ({ children }) => (
<pre className="my-4 overflow-x-auto rounded-md bg-gray-900 p-4 text-sm text-gray-100">
{children}
</pre>
),
code: ({ className: cls, children }) => {
// 行内 code无 language- 前缀)→ 灰底圆角;块级 code 由 <pre> 包裹
const isBlock = typeof cls === 'string' && cls.includes('language-');
if (isBlock) {
return <code className={cls}>{children}</code>;
}
return (
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-sm text-pink-600">
{children}
</code>
);
},
table: ({ children }) => (
<table className="my-4 w-full border-collapse border border-gray-300 text-sm">
{children}
</table>
),
th: ({ children }) => (
<th className="border border-gray-300 bg-gray-50 px-3 py-2 text-left font-medium">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-gray-300 px-3 py-2">{children}</td>
),
hr: () => <hr className="my-6 border-gray-200" />,
};
/**
*
* - HTML RichEditor dangerouslySetInnerHTML
* - Markdown .md react-markdown React
* `.prose-rich`
*
* MD suppressHydrationWarning react-markdown
* SSR/HTML hydration
*/
export function ContentView({ content, className, format }: ContentViewProps) {
if (!content?.trim()) return null;
const wrapperClass = className ? `prose-rich ${className}` : 'prose-rich';
const isHtml =
format === 'html' ? true : format === 'markdown' ? false : isHTMLContent(content);
if (isHtml) {
return (
<div
className={wrapperClass}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
}
return (
<div className={wrapperClass} suppressHydrationWarning>
<ReactMarkdown components={mdComponents}>{content}</ReactMarkdown>
</div>
);
}

View File

@ -0,0 +1,41 @@
import Link from 'next/link';
export function CtaSection({ tel }: { tel?: string | null }) {
return (
<section className="relative overflow-hidden bg-slate-950 py-24">
{/* 渐变底 */}
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-[#0a0e27] to-black" />
{/* 辉光 */}
<div className="absolute left-1/2 top-0 h-72 w-[600px] -translate-x-1/2 rounded-full bg-brand-600/20 blur-[120px]" />
<div className="absolute bottom-0 right-1/4 h-56 w-56 rounded-full bg-brand-500/10 blur-[100px]" />
<div className="container-page relative text-center">
<h2 className="text-3xl font-bold text-white sm:text-4xl">
</h2>
<p className="mt-4 text-lg text-brand-200">
&ldquo;&rdquo;便
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link
href="/contact"
className="rounded-lg bg-white px-8 py-3.5 text-base font-semibold text-brand-700 shadow-xl transition-all hover:scale-105"
>
</Link>
<Link
href="/products"
className="rounded-lg border border-white/20 px-8 py-3.5 text-base font-medium text-white transition-colors hover:bg-white/10"
>
</Link>
</div>
{tel && (
<p className="mt-6 text-sm text-brand-300">
<span className="font-semibold text-white">{tel}</span>
</p>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,129 @@
import {
CreditCard,
Wrench,
Megaphone,
ClipboardCheck,
Users,
BarChart3,
ArrowRight,
type LucideIcon,
} from 'lucide-react';
interface Feature {
icon: LucideIcon;
title: string;
desc: string;
tags: string[];
}
const FEATURES: Feature[] = [
{
icon: CreditCard,
title: '物业缴费',
desc: '支持微信/支付宝在线缴费自动账单生成逾期智能提醒财务报表一键导出收费率提升40%+。',
tags: ['微信支付', '自动账单', '财务报表'],
},
{
icon: Wrench,
title: '在线报修',
desc: '业主一键报修工单自动派发维修进度实时追踪评价闭环管理平均响应时间缩短至5分钟。',
tags: ['智能派单', '进度追踪', '评价闭环'],
},
{
icon: Megaphone,
title: '社区公告',
desc: '图文通知一键群发已读未读精准统计分类管理历史可追溯触达率达99%以上。',
tags: ['一键群发', '已读统计', '分类管理'],
},
{
icon: ClipboardCheck,
title: '巡检管理',
desc: '设备设施巡检打卡隐患上报闭环NFC/二维码防作弊巡更,确保安保无死角。',
tags: ['NFC打卡', '隐患上报', '防作弊'],
},
{
icon: Users,
title: '业主服务',
desc: '访客邀请、投诉建议、投票表决、社区活动业主自治一站式平台满意度提升60%。',
tags: ['访客邀请', '投票表决', '满意度+60%'],
},
{
icon: BarChart3,
title: '数据看板',
desc: '收费率、报修响应、满意度等核心指标可视化,管理驾驶舱实时呈现,辅助科学决策。',
tags: ['实时大屏', '多维分析', '决策辅助'],
},
];
export function FeatureGrid() {
return (
<section className="relative overflow-hidden bg-gradient-to-b from-white to-slate-50 py-20">
{/* 顶部柔光 */}
<div className="pointer-events-none absolute left-1/2 top-0 h-64 w-[700px] -translate-x-1/2 rounded-full bg-brand-200/20 blur-[120px]" />
<div className="container-page relative">
{/* 标题区 */}
<div className="mb-12 text-center">
<span className="text-xs font-semibold uppercase tracking-widest text-brand-600">
Core Features
</span>
<h2 className="mt-3 text-3xl font-bold text-slate-900 sm:text-4xl">
</h2>
<div className="mx-auto mt-4 h-1 w-12 rounded-full bg-slate-900" />
<p className="mx-auto mt-4 max-w-xl text-base text-slate-500">
</p>
</div>
{/* 功能卡片 */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((f, i) => (
<div
key={f.title}
className="group relative rounded-2xl border border-slate-200/70 bg-white p-7 shadow-[0_1px_3px_rgba(15,23,42,0.04),0_8px_24px_-12px_rgba(79,70,229,0.12)] transition-all duration-300 hover:-translate-y-1 hover:border-brand-200 hover:shadow-[0_4px_12px_rgba(15,23,42,0.06),0_20px_40px_-16px_rgba(79,70,229,0.25)]"
>
{/* 序号 */}
<span className="absolute right-5 top-5 font-mono text-2xl font-bold text-slate-100 transition-colors group-hover:text-brand-100">
{String(i + 1).padStart(2, '0')}
</span>
{/* 图标 - 靛蓝渐变 */}
<div className="relative flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg shadow-brand-600/30 transition-transform duration-300 group-hover:scale-110">
<f.icon className="h-6 w-6" />
</div>
{/* 标题 */}
<h3 className="mt-5 text-lg font-bold text-slate-900">
{f.title}
</h3>
{/* 描述 */}
<p className="mt-2 text-sm leading-7 text-slate-500">
{f.desc}
</p>
{/* 标签 */}
<div className="mt-4 flex flex-wrap gap-2">
{f.tags.map((tag) => (
<span
key={tag}
className="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600"
>
{tag}
</span>
))}
</div>
{/* 底部箭头 */}
<div className="mt-4 flex items-center gap-1.5 text-brand-600 opacity-0 transition-all duration-300 group-hover:opacity-100">
<span className="text-xs font-medium"></span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,53 @@
import Link from 'next/link';
import type { SiteConfig } from '@/lib/types';
export function Footer({ config }: { config: SiteConfig | null }) {
const year = new Date().getFullYear();
return (
<footer className="bg-[#070a1f] text-slate-400">
<div className="container-page grid grid-cols-1 gap-8 py-12 md:grid-cols-4">
<div>
<h3 className="mb-3 text-base font-semibold text-white">
{config?.siteName ?? '智管物业'}
</h3>
<p className="text-sm leading-7 text-gray-400">
线SaaS解决方案
</p>
</div>
<div>
<h4 className="mb-3 text-sm font-semibold text-white"></h4>
<ul className="space-y-2 text-sm text-gray-400">
<li><Link href="/products" className="hover:text-brand-300"></Link></li>
<li><Link href="/products" className="hover:text-brand-300">线</Link></li>
<li><Link href="/products" className="hover:text-brand-300"></Link></li>
<li><Link href="/products" className="hover:text-brand-300"></Link></li>
</ul>
</div>
<div>
<h4 className="mb-3 text-sm font-semibold text-white"></h4>
<ul className="space-y-2 text-sm text-gray-400">
<li><Link href="/" className="hover:text-brand-300"></Link></li>
<li><Link href="/products" className="hover:text-brand-300"></Link></li>
<li><Link href="/news" className="hover:text-brand-300"></Link></li>
<li><Link href="/about" className="hover:text-brand-300"></Link></li>
<li><Link href="/contact" className="hover:text-brand-300"></Link></li>
</ul>
</div>
<div>
<h4 className="mb-3 text-sm font-semibold text-white"></h4>
<ul className="space-y-2 text-sm text-gray-400">
{config?.tel && <li>{config.tel}</li>}
{config?.email && <li>{config.email}</li>}
{config?.address && <li>{config.address}</li>}
</ul>
</div>
</div>
<div className="border-t border-white/[0.06] py-4">
<div className="container-page flex flex-col items-center justify-between gap-2 text-xs text-gray-500 sm:flex-row">
<p>{config?.copyright || `© ${year} ${config?.siteName ?? '智管物业'} 版权所有`}</p>
{config?.icp && <p>{config.icp}</p>}
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,92 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { usePathname } from 'next/navigation';
import { Menu, X, Building2, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface NavLink {
href: string;
label: string;
}
const LINKS: NavLink[] = [
{ href: '/', label: '首页' },
{ href: '/about', label: '关于我们' },
{ href: '/products', label: '产品方案' },
{ href: '/news', label: '新闻资讯' },
{ href: '/team', label: '团队介绍' },
{ href: '/contact', label: '联系我们' },
];
export function Header({ siteName }: { siteName: string }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<header className="sticky top-0 z-40 w-full border-b border-slate-200 bg-white">
<div className="container-page flex h-16 items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-slate-800 to-slate-900 text-white shadow-md shadow-slate-900/25 ring-1 ring-inset ring-white/10">
<Building2 className="h-5 w-5" />
</span>
<span className="text-base font-bold text-slate-900">
{siteName}
</span>
</Link>
<nav className="hidden items-center gap-6 md:flex">
{LINKS.map((l) => {
const active = pathname === l.href || pathname.startsWith(l.href + '/');
return (
<Link
key={l.href}
href={l.href}
className={cn(
'text-sm font-medium transition-colors',
active ? 'text-brand-600' : 'text-slate-600 hover:text-slate-900',
)}
>
{l.label}
</Link>
);
})}
</nav>
<Link
href="/contact"
className="hidden items-center gap-1 rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-md shadow-slate-900/20 transition-all hover:bg-black hover:shadow-lg hover:shadow-slate-900/30 md:inline-flex"
>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
<button
className="text-slate-600 md:hidden"
aria-label="菜单"
onClick={() => setOpen((v) => !v)}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{open && (
<div className="border-t border-slate-200/70 bg-white md:hidden">
<nav className="container-page flex flex-col py-2">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="py-2 text-sm text-slate-600 hover:text-brand-600"
onClick={() => setOpen(false)}
>
{l.label}
</Link>
))}
</nav>
</div>
)}
</header>
);
}

186
components/front/Hero.tsx Normal file
View File

@ -0,0 +1,186 @@
import Link from 'next/link';
import {
Building2,
CreditCard,
Wrench,
Bell,
ShieldCheck,
Smartphone,
} from 'lucide-react';
export function Hero() {
return (
<section className="relative overflow-hidden bg-[#0a0e27]">
{/* ===== 背景 ===== */}
<div className="pointer-events-none absolute inset-0">
{/* 深色渐变底 */}
<div className="absolute inset-0 bg-gradient-to-br from-[#0a0e27] via-[#0d1130] to-[#0a0e27]" />
{/* 网格底纹 */}
<div
className="absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
{/* 靛蓝辉光 - 左上(克制) */}
<div className="absolute -left-20 top-0 h-80 w-80 rounded-full bg-brand-600/15 blur-[120px]" />
{/* 靛蓝辉光 - 右下(克制) */}
<div className="absolute -right-10 bottom-0 h-72 w-72 rounded-full bg-brand-700/15 blur-[120px]" />
{/* === 几何图形 === */}
{/* 大圆环 - 左上 */}
<div className="hero-anim-spin absolute -left-20 -top-20 h-72 w-72 rounded-full border border-white/10" />
{/* 方块 - 左下 */}
<div className="hero-anim-float absolute bottom-16 left-[8%] h-14 w-14 rounded-lg border border-white/10 bg-white/[0.03]" />
{/* 小菱形 - 左中 */}
<div className="hero-anim-float-rev absolute left-[5%] top-1/2 h-8 w-8 rotate-45 rounded-sm border border-white/10 bg-white/[0.03]" />
{/* 加号 - 左中偏上 */}
<div className="hero-anim-float absolute left-[3%] top-[25%] text-2xl font-light text-white/15">+</div>
{/* 小方块 - 左中偏下 */}
<div className="hero-anim-float-rev absolute left-[12%] bottom-[22%] h-6 w-6 rounded border border-white/10 bg-white/[0.03]" style={{ animationDelay: '2s' }} />
{/* 小圆 - 左侧 */}
<div className="hero-anim-pulse absolute left-[18%] top-[35%] h-2.5 w-2.5 rounded-full bg-brand-400/40" style={{ animationDelay: '0.8s' }} />
{/* === 右侧区域 === */}
<div className="hero-anim-spin absolute right-10 top-12 h-32 w-32 rounded-full border-2 border-dashed border-white/10" style={{ animationDuration: '24s' }} />
<div className="hero-anim-float-rev absolute right-[12%] top-1/3 h-10 w-10 rotate-45 rounded-sm border border-white/10 bg-white/[0.03]" />
<div className="hero-anim-pulse absolute -bottom-16 right-[5%] h-48 w-48 rounded-full border border-white/8" />
<div className="hero-anim-float absolute right-[6%] top-[28%] text-3xl font-light text-white/10">+</div>
<div className="hero-anim-spin absolute right-[8%] bottom-[30%] h-16 w-16 rounded-full border border-white/10" style={{ animationDuration: '20s', animationDirection: 'reverse' }} />
<div className="hero-anim-float-rev absolute right-[20%] bottom-[12%] h-0 w-0" style={{ borderLeft: '8px solid transparent', borderRight: '8px solid transparent', borderBottom: '14px solid rgba(255,255,255,0.1)' }} />
{/* === 中间区域 === */}
<div className="hero-anim-pulse absolute left-[35%] top-[20%] h-3 w-3 rounded-full bg-brand-400/40" />
<div className="hero-anim-pulse absolute right-[28%] bottom-[25%] h-2 w-2 rounded-full bg-white/20" style={{ animationDelay: '1.5s' }} />
<div className="hero-anim-pulse absolute left-[15%] bottom-[30%] h-2.5 w-2.5 rounded-full bg-brand-400/30" style={{ animationDelay: '0.8s' }} />
<div className="hero-anim-float absolute right-[40%] top-[15%] font-mono text-2xl font-bold text-white/10">{'{ }'}</div>
<div className="hero-anim-float-rev absolute left-[60%] bottom-[20%] font-mono text-xl font-bold text-white/8">{'</>'}</div>
<div className="hero-anim-float absolute left-[42%] top-[55%] h-7 w-7 rotate-12 rounded-md border border-white/10 bg-white/[0.03]" />
<div className="hero-anim-float-rev absolute left-[55%] top-[18%] h-10 w-10 rounded-full border border-dashed border-white/10" style={{ animationDelay: '1s' }} />
<div className="hero-anim-float-rev absolute left-[38%] bottom-[18%] text-xl font-light text-white/10">+</div>
<div className="hero-anim-spin absolute left-[48%] top-[30%] h-5 w-5 rounded border border-white/10" style={{ animationDuration: '15s' }} />
<div className="hero-anim-pulse absolute left-[50%] top-[65%] h-2 w-2 rounded-full bg-brand-400/30" style={{ animationDelay: '2.5s' }} />
<div className="hero-anim-float absolute left-[30%] top-[12%] h-12 w-12 rounded-full border border-white/8" />
<div className="hero-anim-float-rev absolute left-[45%] bottom-[10%] h-px w-16 bg-gradient-to-r from-transparent via-white/15 to-transparent" />
</div>
{/* ===== 内容区 ===== */}
<div className="container-page relative grid items-center gap-12 py-16 lg:grid-cols-2 lg:py-24">
{/* 左:文案 */}
<div className="text-center lg:text-left">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-1.5 text-sm text-gray-300 backdrop-blur-sm">
<Smartphone className="h-4 w-4 text-brand-400" />
· · SaaS
</span>
<h1 className="mt-6 text-4xl font-bold leading-tight text-white sm:text-5xl">
<br />
<span className="bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">
</span>
</h1>
<p className="mt-5 text-xl font-medium text-gray-300">
&ldquo;&rdquo;
</p>
<p className="mt-3 max-w-lg text-base leading-7 text-gray-400 lg:mx-0 mx-auto">
</p>
<div className="mt-8 flex flex-col gap-4 sm:flex-row lg:justify-start justify-center">
<Link
href="/contact"
className="rounded-lg bg-brand-600 px-7 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-900/50 transition-all hover:bg-brand-500"
>
</Link>
<Link
href="/manual"
className="rounded-lg border border-white/15 bg-white/5 px-7 py-3 text-sm font-medium text-gray-200 backdrop-blur-sm transition-colors hover:bg-white/10"
>
使
</Link>
</div>
</div>
{/* 右:配图区 */}
<div className="relative hidden lg:block">
<div className="hero-anim-float relative mx-auto w-full max-w-md rounded-2xl border border-white/15 bg-white p-5 shadow-2xl">
{/* 顶部状态栏 */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-brand-600">
<Building2 className="h-4 w-4 text-white" />
</span>
<span className="text-sm font-semibold text-gray-800"></span>
</div>
<span className="text-xs text-gray-400"></span>
</div>
{/* 功能卡片 */}
<div className="grid grid-cols-3 gap-3">
{[
{ icon: CreditCard, label: '物业缴费' },
{ icon: Wrench, label: '在线报修' },
{ icon: Bell, label: '社区公告' },
{ icon: ShieldCheck, label: '访客邀请' },
{ icon: Building2, label: '投诉建议' },
{ icon: Smartphone, label: '社区活动' },
].map((item) => (
<div
key={item.label}
className="flex flex-col items-center gap-1.5 rounded-lg border border-gray-100 bg-gray-50 p-3"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50">
<item.icon className="h-4 w-4 text-brand-600" />
</span>
<span className="text-xs text-gray-600">{item.label}</span>
</div>
))}
</div>
{/* 缴费条 */}
<div className="mt-4 rounded-lg bg-gray-50 p-3">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"></span>
<span className="text-xs font-medium text-brand-600"></span>
</div>
<div className="mt-1 flex items-end justify-between">
<span className="text-lg font-bold text-gray-900">¥286.00</span>
<span className="rounded-md bg-brand-600 px-3 py-1 text-xs font-medium text-white">
</span>
</div>
</div>
</div>
{/* 漂浮卡片 - 报修工单 */}
<div className="hero-anim-float-rev absolute -left-6 top-8 rounded-xl border border-gray-100 bg-white p-3 shadow-xl">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50">
<Wrench className="h-4 w-4 text-brand-600" />
</span>
<div>
<div className="text-xs font-medium text-gray-800"></div>
<div className="text-xs text-gray-400"> · 5</div>
</div>
</div>
</div>
{/* 漂浮卡片 - 收费率 */}
<div className="hero-anim-float absolute -right-4 bottom-10 rounded-xl border border-gray-100 bg-white p-3 shadow-xl">
<div className="text-xs text-gray-400"></div>
<div className="mt-0.5 flex items-baseline gap-1">
<span className="text-xl font-bold text-brand-600">98.5</span>
<span className="text-xs text-gray-400">%</span>
</div>
<div className="mt-1.5 h-1.5 w-20 overflow-hidden rounded-full bg-gray-100">
<div className="h-full w-[98%] rounded-full bg-brand-500" />
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,130 @@
import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
import { ManualTreeNav } from './ManualTreeNav';
import { ContentView } from './ContentView';
import type { Manual, ManualTreeNode } from '@/lib/types';
interface ManualLayoutProps {
tree: ManualTreeNode[];
/** 当前文档(含正文);为 null 表示无选中 */
doc: Manual | null;
}
/** 扁平化树为「仅文档」节点,便于查找前一篇/后一篇 */
function flattenDocs(nodes: ManualTreeNode[]): { id: number; title: string }[] {
const out: { id: number; title: string }[] = [];
const walk = (list: ManualTreeNode[]) => {
for (const n of list) {
if (n.type === 1) out.push({ id: n.id, title: n.title });
if (n.children.length > 0) walk(n.children);
}
};
walk(nodes);
return out;
}
/** 构建从根到当前节点的标题面包屑路径 */
function findPath(
nodes: ManualTreeNode[],
id: number,
prefix: string[] = [],
): string[] | null {
for (const n of nodes) {
const path = [...prefix, n.title];
if (n.id === id) return path;
if (n.children.length > 0) {
const sub = findPath(n.children, id, path);
if (sub) return sub;
}
}
return null;
}
/** 使用手册左右布局外壳 */
export function ManualLayout({ tree, doc }: ManualLayoutProps) {
const docs = flattenDocs(tree);
const currentIndex = doc ? docs.findIndex((d) => d.id === doc.id) : -1;
const prev = currentIndex > 0 ? docs[currentIndex - 1] : null;
const next =
currentIndex >= 0 && currentIndex < docs.length - 1
? docs[currentIndex + 1]
: null;
const breadcrumb = doc ? findPath(tree, doc.id) : null;
return (
<div className="container-page py-8">
<div className="grid gap-6 md:grid-cols-[260px_minmax(0,1fr)] lg:gap-10">
{/* 左:树形菜单 */}
<aside className="md:sticky md:top-20 md:max-h-[calc(100vh-6rem)] md:overflow-y-auto">
<div className="mb-3 px-2 text-sm font-semibold text-slate-900">
使
</div>
<ManualTreeNav nodes={tree} />
</aside>
{/* 右:正文 */}
<article className="min-w-0">
{doc ? (
<>
{breadcrumb && breadcrumb.length > 1 && (
<nav className="mb-3 flex flex-wrap items-center gap-1 text-xs text-slate-500">
{breadcrumb.slice(0, -1).map((seg, i) => (
<span key={i} className="flex items-center gap-1">
<span>{seg}</span>
<ChevronRight className="h-3 w-3" />
</span>
))}
</nav>
)}
<h1 className="border-b border-slate-200 pb-3 text-2xl font-bold text-slate-900">
{doc.title}
</h1>
{doc.content ? (
<ContentView
content={doc.content}
format={doc.contentFormat}
className="mt-6"
/>
) : (
<p className="mt-6 text-sm text-slate-400"></p>
)}
{/* 上一篇 / 下一篇 */}
{(prev || next) && (
<div className="mt-10 flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4 text-sm">
{prev ? (
<Link
href={`/manual/${prev.id}`}
className="flex items-center gap-1 text-slate-600 hover:text-brand-600"
>
<span className="text-slate-400"></span>
<span className="truncate">{prev.title}</span>
</Link>
) : (
<span />
)}
{next && (
<Link
href={`/manual/${next.id}`}
className="flex items-center gap-1 text-slate-600 hover:text-brand-600"
>
<span className="text-slate-400"></span>
<span className="truncate">{next.title}</span>
</Link>
)}
</div>
)}
</>
) : (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-12 text-center">
<p className="text-slate-500"></p>
<p className="mt-1 text-xs text-slate-400">
使
</p>
</div>
)}
</article>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Folder, FileText } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ManualTreeNode } from '@/lib/types';
interface TreeNavProps {
nodes: ManualTreeNode[];
}
/** 递归渲染节点(含子孙) */
function TreeItem({ node, depth = 0 }: { node: ManualTreeNode; depth?: number }) {
const pathname = usePathname();
const href = `/manual/${node.id}`;
const active = pathname === href;
// ===== 顶层目录 → 分区标题(带分隔线 + 缩进引导线) =====
if (depth === 0 && node.type === 0) {
return (
<div className="border-t border-gray-100 pt-3 first:border-t-0 first:pt-0">
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold uppercase tracking-wider text-slate-400">
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-500" />
{node.title}
</div>
{node.children.length > 0 && (
<div className="mt-1 ml-3 space-y-0.5 border-l border-gray-100 pl-2">
{node.children.map((c) => (
<TreeItem key={c.id} node={c} depth={depth + 1} />
))}
</div>
)}
</div>
);
}
// ===== 嵌套目录 → 子标题(带缩进引导线) =====
if (node.type === 0) {
return (
<div className="pt-2">
<div className="flex items-center gap-1.5 px-2 py-1 text-sm font-semibold text-slate-700">
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-400" />
{node.title}
</div>
{node.children.length > 0 && (
<div className="mt-0.5 ml-3 space-y-0.5 border-l border-gray-100 pl-2">
{node.children.map((c) => (
<TreeItem key={c.id} node={c} depth={depth + 1} />
))}
</div>
)}
</div>
);
}
// ===== 文档 → 可点击链接 =====
return (
<Link
href={href}
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors',
active
? 'bg-brand-50 font-medium text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)}
>
<FileText
className={cn(
'h-3.5 w-3.5 shrink-0',
active ? 'text-brand-600' : 'text-slate-400',
)}
/>
<span className="truncate">{node.title}</span>
</Link>
);
}
/** 使用手册左侧树形导航(始终展开) */
export function ManualTreeNav({ nodes }: TreeNavProps) {
if (nodes.length === 0) {
return (
<div className="px-3 py-6 text-center text-xs text-slate-400">
</div>
);
}
return (
<nav className="space-y-1">
{nodes.map((n) => (
<TreeItem key={n.id} node={n} />
))}
</nav>
);
}

View File

@ -0,0 +1,44 @@
import Link from 'next/link';
import type { News } from '@/lib/types';
import { formatDate, resolveUploadUrl } from '@/lib/utils';
export function NewsCard({ news }: { news: News }) {
return (
<Link
href={`/news/${news.id}`}
className="group flex gap-4 rounded-xl border border-slate-200/70 bg-white p-4 shadow-[0_1px_3px_rgba(15,23,42,0.04)] transition-all duration-300 hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-[0_12px_28px_-12px_rgba(79,70,229,0.22)]"
>
{news.cover && (
<div className="h-20 w-28 shrink-0 overflow-hidden rounded-lg bg-slate-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveUploadUrl(news.cover)}
alt={news.title}
className="h-full w-full object-cover"
/>
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{news.isTop === 1 && (
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">
</span>
)}
{news.category?.name && (
<span className="text-xs text-brand-600">{news.category.name}</span>
)}
</div>
<h3 className="mt-1 line-clamp-1 font-semibold text-slate-900 transition-colors group-hover:text-brand-600">
{news.title}
</h3>
<p className="mt-1 line-clamp-2 text-sm text-slate-500">
{news.intro || '点击查看详情'}
</p>
<p className="mt-2 text-xs text-slate-400">
{formatDate(news.createdAt)}
</p>
</div>
</Link>
);
}

View File

@ -0,0 +1,36 @@
import Link from 'next/link';
import type { Product } from '@/lib/types';
import { resolveUploadUrl } from '@/lib/utils';
export function ProductCard({ product }: { product: Product }) {
return (
<Link
href={`/products/${product.id}`}
className="group block overflow-hidden rounded-xl border border-slate-200/70 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.04)] transition-all duration-300 hover:-translate-y-1 hover:border-brand-200 hover:shadow-[0_12px_32px_-12px_rgba(79,70,229,0.25)]"
>
<div className="aspect-[4/3] overflow-hidden bg-slate-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveUploadUrl(product.cover)}
alt={product.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<div className="p-4">
<h3 className="text-base font-semibold text-slate-900 transition-colors group-hover:text-brand-600">
{product.name}
</h3>
{product.desc && (
<p className="mt-1 line-clamp-2 text-sm text-slate-500">
{product.desc}
</p>
)}
{product.category?.name && (
<span className="mt-3 inline-block rounded bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-600">
{product.category.name}
</span>
)}
</div>
</Link>
);
}

View File

@ -0,0 +1,47 @@
import { ReactNode } from 'react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
interface SectionProps {
title: string;
subtitle?: string;
moreText?: string;
moreHref?: string;
className?: string;
children: ReactNode;
}
export function Section({
title,
subtitle,
moreText = '查看更多',
moreHref,
className,
children,
}: SectionProps) {
return (
<section className={cn('bg-white py-12', className)}>
<div className="container-page">
<div className="mb-8 flex items-end justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-900 sm:text-3xl">
{title}
</h2>
{subtitle && (
<p className="mt-2 text-sm text-slate-500">{subtitle}</p>
)}
</div>
{moreHref && (
<Link
href={moreHref}
className="text-sm text-brand-600 transition-colors hover:text-brand-700"
>
{moreText}
</Link>
)}
</div>
{children}
</div>
</section>
);
}

View File

@ -0,0 +1,113 @@
import Image from 'next/image';
interface Scenario {
num: string;
tag: string;
title: string;
subtitle: string;
desc: string;
image: string;
}
const SCENARIOS: Scenario[] = [
{
num: '01',
tag: '业主端',
title: '指尖上的物业',
subtitle: '服务触手可及',
desc: '无需下载 APP微信一键直达。支持在线缴费、手机报修、投诉建议及社区公告查看。',
image: '/customer.png',
},
{
num: '02',
tag: '员工端',
title: '口袋里的管家',
subtitle: '告别纸质办公',
desc: '专为一线人员打造。工单秒级响应,巡检拍照留痕,抄表自动计算,随时随地处理现场事务。',
image: '/staff.png',
},
{
num: '03',
tag: '管理后台',
title: '全景驾驶舱',
subtitle: '盈亏一目了然',
desc: '聚合财务、工单、人员数据。支持多项目集中管理,多维度经营报表。算清每一笔账,管好每一个人。',
image: '/dashboard.png',
},
];
export function SolutionShowcase() {
return (
<section className="bg-white py-16">
<div className="container-page">
<div className="mx-auto mb-12 max-w-2xl text-center">
<span className="text-xs font-semibold uppercase tracking-widest text-brand-600">
Product
</span>
<h2 className="mt-3 text-3xl font-bold text-slate-900 sm:text-4xl">
</h2>
<div className="mx-auto mt-4 h-1 w-12 rounded-full bg-slate-900" />
<p className="mx-auto mt-4 max-w-xl text-base text-slate-500">
</p>
</div>
<div className="space-y-12">
{SCENARIOS.map((s, i) => {
const reversed = i % 2 === 1;
return (
<div
key={s.num}
className="grid items-center gap-8 lg:grid-cols-12 lg:gap-10"
>
{/* 文案区 */}
<div className={`lg:col-span-5 ${reversed ? 'lg:order-2 lg:col-start-8' : ''}`}>
{/* 序号 + 端标签 */}
<div className="flex items-center gap-3">
<span className="font-mono text-sm font-bold text-brand-600">
{s.num}
</span>
<span className="h-px w-6 bg-slate-300" />
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{s.tag}
</span>
</div>
<h3 className="mt-4 text-2xl font-bold text-slate-900">
{s.title}
</h3>
<p className="mt-1.5 text-lg font-medium text-slate-900">
{s.subtitle}
</p>
<div className="mt-3 h-0.5 w-12 rounded-full bg-slate-900" />
<p className="mt-4 text-sm leading-7 text-slate-600">
{s.desc}
</p>
</div>
{/* 配图区 */}
<div className={`lg:col-span-7 ${reversed ? 'lg:order-1 lg:col-start-1' : ''}`}>
<div className="group relative flex items-center justify-center overflow-hidden rounded-2xl border border-slate-200/70 bg-gradient-to-br from-slate-50 to-white py-8 shadow-[0_4px_12px_rgba(15,23,42,0.04),0_24px_48px_-20px_rgba(15,23,42,0.12)] transition-all duration-500 hover:shadow-[0_8px_20px_rgba(15,23,42,0.06),0_32px_64px_-24px_rgba(15,23,42,0.2)]">
{/* 卡片内辉光 */}
<div className="pointer-events-none absolute left-1/2 top-1/2 h-48 w-80 -translate-x-1/2 -translate-y-1/2 rounded-full bg-slate-200/40 blur-[80px] transition-all duration-500 group-hover:bg-slate-300/50" />
<div className="showcase-anim-float relative z-10 flex items-center justify-center transition-transform duration-500 group-hover:scale-[1.03]">
<Image
src={s.image}
alt={`${s.title} - ${s.subtitle}`}
width={500}
height={700}
className="h-[340px] w-auto max-w-full rounded-lg"
unoptimized
/>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,26 @@
const STATS = [
{ value: '500+', label: '服务社区' },
{ value: '100万+', label: '业主用户' },
{ value: '99.9%', label: '系统可用率' },
{ value: '7×24', label: '技术支持' },
];
export function StatsBar() {
return (
<section className="border-t border-white/5 bg-slate-950 py-10">
<div className="container-page grid grid-cols-2 gap-y-8 md:grid-cols-4">
{STATS.map((s, i) => (
<div
key={i}
className="flex flex-col items-center border-r border-white/10 last:border-r-0 md:px-4"
>
<span className="text-3xl font-bold text-white">
{s.value}
</span>
<span className="mt-1.5 text-sm text-slate-400">{s.label}</span>
</div>
))}
</div>
</section>
);
}

32
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,32 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-brand-100 text-brand-700',
secondary: 'border-transparent bg-gray-100 text-gray-700',
success: 'border-transparent bg-green-100 text-green-700',
warning: 'border-transparent bg-yellow-100 text-yellow-700',
destructive: 'border-transparent bg-red-100 text-red-700',
outline: 'border-gray-300 text-gray-700',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

55
components/ui/button.tsx Normal file
View File

@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-brand-600 text-white hover:bg-brand-700',
destructive: 'bg-red-600 text-white hover:bg-red-700',
outline:
'border border-gray-300 bg-white hover:bg-gray-50 hover:text-gray-900',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'hover:bg-gray-100 hover:text-gray-900',
link: 'text-brand-600 underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

77
components/ui/card.tsx Normal file
View File

@ -0,0 +1,77 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border border-gray-200 bg-white text-gray-900 shadow-sm',
className,
)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-gray-500', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};

104
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,104 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-gray-200 bg-white p-6 shadow-lg duration-200 sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-gray-500', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
};

25
components/ui/input.tsx Normal file
View File

@ -0,0 +1,25 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

22
components/ui/label.tsx Normal file
View File

@ -0,0 +1,22 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

27
components/ui/select.tsx Normal file
View File

@ -0,0 +1,27 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface SelectProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<select
ref={ref}
className={cn(
'flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children}
</select>
);
},
);
Select.displayName = 'Select';
export { Select };

80
components/ui/table.tsx Normal file
View File

@ -0,0 +1,80 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-gray-50 data-[state=selected]::bg-gray-100',
className,
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };

View File

@ -0,0 +1,24 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };

135
lib/admin-services.ts Normal file
View File

@ -0,0 +1,135 @@
/**
* API JWT
*/
import { http, type Paginated } from './api';
import type {
AdminInfo,
AdminUser,
LoginResult,
Manual,
ManualTreeNode,
Message,
News,
NewsCategory,
Product,
ProductCategory,
SiteConfig,
Team,
} from './types';
export const adminApi = {
// ---------- 鉴权 ----------
login: (payload: { username: string; password: string }) =>
http.post<unknown, LoginResult>('/admin/auth/login', payload),
getProfile: () =>
http.get<unknown, AdminInfo & { id: number }>('/admin/auth/profile'),
changePassword: (payload: { oldPassword: string; newPassword: string }) =>
http.post<unknown, unknown>('/admin/auth/change-password', payload),
// ---------- 产品分类 ----------
productCategoryAll: (params?: Record<string, unknown>) =>
http.get<unknown, ProductCategory[]>('/admin/product-category', { params }),
productCategoryDetail: (id: number) =>
http.get<unknown, ProductCategory>(`/admin/product-category/${id}`),
productCategoryCreate: (payload: Partial<ProductCategory>) =>
http.post<unknown, ProductCategory>('/admin/product-category', payload),
productCategoryUpdate: (id: number, payload: Partial<ProductCategory>) =>
http.put<unknown, unknown>(`/admin/product-category/${id}`, payload),
productCategoryDelete: (id: number) =>
http.delete<unknown, unknown>(`/admin/product-category/${id}`),
// ---------- 产品 ----------
productPaginate: (params: Record<string, unknown>) =>
http.get<unknown, Paginated<Product>>('/admin/product', { params }),
productDetail: (id: number) =>
http.get<unknown, Product>(`/admin/product/${id}`),
productCreate: (payload: Partial<Product>) =>
http.post<unknown, Product>('/admin/product', payload),
productUpdate: (id: number, payload: Partial<Product>) =>
http.put<unknown, unknown>(`/admin/product/${id}`, payload),
productDelete: (id: number) =>
http.delete<unknown, unknown>(`/admin/product/${id}`),
// ---------- 新闻分类 ----------
newsCategoryAll: (params?: Record<string, unknown>) =>
http.get<unknown, NewsCategory[]>('/admin/news-category', { params }),
newsCategoryDetail: (id: number) =>
http.get<unknown, NewsCategory>(`/admin/news-category/${id}`),
newsCategoryCreate: (payload: Partial<NewsCategory>) =>
http.post<unknown, NewsCategory>('/admin/news-category', payload),
newsCategoryUpdate: (id: number, payload: Partial<NewsCategory>) =>
http.put<unknown, unknown>(`/admin/news-category/${id}`, payload),
newsCategoryDelete: (id: number) =>
http.delete<unknown, unknown>(`/admin/news-category/${id}`),
// ---------- 新闻 ----------
newsPaginate: (params: Record<string, unknown>) =>
http.get<unknown, Paginated<News>>('/admin/news', { params }),
newsDetail: (id: number) =>
http.get<unknown, News>(`/admin/news/${id}`),
newsCreate: (payload: Partial<News>) =>
http.post<unknown, News>('/admin/news', payload),
newsUpdate: (id: number, payload: Partial<News>) =>
http.put<unknown, unknown>(`/admin/news/${id}`, payload),
newsDelete: (id: number) =>
http.delete<unknown, unknown>(`/admin/news/${id}`),
// ---------- 团队 ----------
teamAll: (params?: Record<string, unknown>) =>
http.get<unknown, Team[]>('/admin/team', { params }),
teamDetail: (id: number) =>
http.get<unknown, Team>(`/admin/team/${id}`),
teamCreate: (payload: Partial<Team>) =>
http.post<unknown, Team>('/admin/team', payload),
teamUpdate: (id: number, payload: Partial<Team>) =>
http.put<unknown, unknown>(`/admin/team/${id}`, payload),
teamDelete: (id: number) =>
http.delete<unknown, unknown>(`/admin/team/${id}`),
// ---------- 留言 ----------
messagePaginate: (params: Record<string, unknown>) =>
http.get<unknown, Paginated<Message>>('/admin/message', { params }),
messageMarkRead: (id: number) =>
http.put<unknown, unknown>(`/admin/message/${id}/read`),
messageDelete: (id: number) =>
http.delete<unknown, unknown>(`/admin/message/${id}`),
// ---------- 网站配置 ----------
siteConfigGet: () =>
http.get<unknown, SiteConfig>('/admin/site-config'),
siteConfigUpdate: (payload: Partial<SiteConfig>) =>
http.put<unknown, SiteConfig>('/admin/site-config', payload),
// ---------- 管理员账号 ----------
adminUserPaginate: (params: Record<string, unknown>) =>
http.get<unknown, Paginated<AdminUser>>('/admin/admin-user', { params }),
adminUserDetail: (id: number) =>
http.get<unknown, AdminUser>(`/admin/admin-user/${id}`),
adminUserCreate: (
payload: Partial<AdminUser> & { password: string; role?: AdminUser['role'] },
) => http.post<unknown, AdminUser>('/admin/admin-user', payload),
adminUserUpdate: (
id: number,
payload: Pick<AdminUser, 'nickname' | 'avatar' | 'role'>,
) => http.put<unknown, unknown>(`/admin/admin-user/${id}`, payload),
adminUserResetPassword: (id: number, newPassword: string) =>
http.put<unknown, unknown>(`/admin/admin-user/${id}/password`, {
newPassword,
}),
adminUserDelete: (id: number) =>
http.delete<unknown, unknown>(`/admin/admin-user/${id}`),
// ---------- 使用手册 ----------
manualList: (params?: Record<string, unknown>) =>
http.get<unknown, Manual[]>('/admin/manual', { params }),
manualTree: () =>
http.get<unknown, ManualTreeNode[]>('/admin/manual/tree'),
manualDetail: (id: number) =>
http.get<unknown, Manual>(`/admin/manual/${id}`),
manualCreate: (payload: Partial<Manual>) =>
http.post<unknown, Manual>('/admin/manual', payload),
manualUpdate: (id: number, payload: Partial<Manual>) =>
http.put<unknown, unknown>(`/admin/manual/${id}`, payload),
manualDelete: (id: number) =>
http.delete<unknown, unknown>(`/admin/manual/${id}`),
};

98
lib/api.ts Normal file
View File

@ -0,0 +1,98 @@
import axios, {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from 'axios';
/** 后端统一响应体 */
export interface ApiResult<T> {
code: number;
msg: string;
data: T;
path?: string;
timestamp?: string;
}
/** 分页结果 */
export interface Paginated<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api';
const TOKEN_KEY = process.env.NEXT_PUBLIC_TOKEN_KEY ?? 'admin_token';
/** ---------- Token 存取(仅浏览器端) ---------- */
export const tokenStorage = {
get(): string | null {
if (typeof window === 'undefined') return null;
return window.localStorage.getItem(TOKEN_KEY);
},
set(token: string): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(TOKEN_KEY, token);
},
clear(): void {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(TOKEN_KEY);
},
};
/** ---------- axios 实例 ---------- */
export const http: AxiosInstance = axios.create({
baseURL: API_URL,
timeout: 15000,
});
// 请求拦截:自动注入 JWT
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = tokenStorage.get();
if (token) {
config.headers.set('Authorization', `Bearer ${token}`);
}
return config;
});
// 响应拦截:拆包 + 401 跳登录
http.interceptors.response.use(
(response) => {
const body = response.data as ApiResult<unknown>;
if (body && typeof body.code === 'number') {
if (body.code === 200) {
return body.data;
}
// 业务错误抛出UI 层 try/catch 处理
return Promise.reject(new Error(body.msg || '请求失败'));
}
return body;
},
(error: AxiosError<ApiResult<unknown>>) => {
const status = error.response?.status;
const msg =
error.response?.data?.msg ?? error.message ?? '网络异常,请稍后重试';
if (status === 401) {
tokenStorage.clear();
// 避免循环跳转:仅在 /admin 下跳登录
if (typeof window !== 'undefined') {
const path = window.location.pathname;
if (path.startsWith('/admin') && path !== '/admin/login') {
window.location.href = '/admin/login?expired=1';
}
}
}
return Promise.reject(new Error(msg));
},
);
/** ---------- SWR 通用 fetcher ---------- */
export const fetcher = async <T>(url: string): Promise<T> => {
return (await http.get<unknown, T>(url)) as T;
};
/** ---------- 工具:表单提交包装 ---------- */
export async function submitForm<T>(fn: () => Promise<T>): Promise<T> {
return fn();
}

15
lib/content.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* `content` HTML Markdown
*
*
* - RichEditor (Quill) HTML <p>
* - Markdown HTML `<em>` 便 HTML dangerouslySetInnerHTML
*/
const HTML_BLOCK_RE =
/<\/?(p|div|span|h[1-6]|ul|ol|li|table|tbody|thead|tr|td|th|br|img|a|strong|em|b|i|blockquote|pre|code|hr|figure|figcaption|section|article|header|footer|html|body)\b/i;
export function isHTMLContent(s: string | null | undefined): boolean {
if (!s) return false;
return HTML_BLOCK_RE.test(s);
}

48
lib/services.ts Normal file
View File

@ -0,0 +1,48 @@
/**
* API token
*/
import { http } from './api';
import type {
Banner,
Manual,
ManualTreeNode,
News,
NewsCategory,
Paginated,
Product,
ProductCategory,
SiteConfig,
Team,
} from './types';
export const publicApi = {
getSiteConfig: () =>
http.get<unknown, SiteConfig>('/public/site-config'),
getBanners: () =>
http.get<unknown, Banner[]>('/public/banner'),
getProductCategories: () =>
http.get<unknown, ProductCategory[]>('/public/product-category'),
getProducts: (params: Record<string, unknown> = {}) =>
http.get<unknown, Paginated<Product>>('/public/product', { params }),
getProductDetail: (id: number) =>
http.get<unknown, Product>(`/public/product/${id}`),
getNewsCategories: () =>
http.get<unknown, NewsCategory[]>('/public/news-category'),
getNews: (params: Record<string, unknown> = {}) =>
http.get<unknown, Paginated<News>>('/public/news', { params }),
getNewsDetail: (id: number) =>
http.get<unknown, News>(`/public/news/${id}`),
getTeam: () =>
http.get<unknown, Team[]>('/public/team'),
submitMessage: (payload: {
name: string;
phone: string;
email?: string;
content: string;
}) => http.post<unknown, unknown>('/public/message', payload),
// ---------- 使用手册 ----------
getManualTree: () =>
http.get<unknown, ManualTreeNode[]>('/public/manual/tree'),
getManualDetail: (id: number) =>
http.get<unknown, Manual>(`/public/manual/${id}`),
};

152
lib/types.ts Normal file
View File

@ -0,0 +1,152 @@
/** 前后端共享类型定义(禁止 any */
export type AdminRole = 'super_admin' | 'normal';
export interface AdminInfo {
id: number;
username: string;
nickname: string;
avatar: string;
role: AdminRole;
}
export interface AdminUser {
id: number;
username: string;
nickname: string;
avatar: string;
role: AdminRole;
createdAt: string;
updatedAt: string;
}
export interface LoginResult {
token: string;
admin: AdminInfo;
}
export interface Banner {
id: number;
title: string;
image: string;
link: string;
sort: number;
isShow: number;
createdAt: string;
updatedAt: string;
}
export interface ProductCategory {
id: number;
name: string;
sort: number;
isShow: number;
createdAt: string;
updatedAt: string;
}
export interface Product {
id: number;
categoryId: number;
name: string;
cover: string;
desc: string | null;
content: string | null;
sort: number;
isShow: number;
category?: ProductCategory;
createdAt: string;
updatedAt: string;
}
export interface NewsCategory {
id: number;
name: string;
sort: number;
isShow: number;
createdAt: string;
updatedAt: string;
}
export interface News {
id: number;
categoryId: number;
title: string;
cover: string;
intro: string;
content: string;
isTop: number;
status: number;
category?: NewsCategory;
createdAt: string;
updatedAt: string;
}
export interface Team {
id: number;
name: string;
position: string;
avatar: string;
desc: string | null;
sort: number;
isShow: number;
createdAt: string;
updatedAt: string;
}
export interface Message {
id: number;
name: string;
phone: string;
email: string;
content: string;
isRead: number;
createdAt: string;
}
export interface SiteConfig {
id: number;
siteName: string;
logo: string;
tel: string;
address: string;
email: string;
copyright: string;
icp: string;
aboutTitle: string;
aboutContent: string | null;
createdAt: string;
updatedAt: string;
}
/** 使用手册节点类型0=目录节点 1=文档节点 */
export type ManualNodeType = 0 | 1;
/** 使用手册正文格式 */
export type ManualContentFormat = 'html' | 'markdown';
export interface Manual {
id: number;
parentId: number | null;
title: string;
/** 0=目录 1=文档 */
type: ManualNodeType;
content: string | null;
/** 正文格式html=富文本 markdown=Markdown旧数据可能缺失按 html 兜底) */
contentFormat: ManualContentFormat;
sort: number;
isShow: number;
createdAt: string;
updatedAt: string;
}
/** 树形节点(不含正文,用于菜单) */
export interface ManualTreeNode {
id: number;
parentId: number | null;
title: string;
type: ManualNodeType;
sort: number;
isShow: number;
children: ManualTreeNode[];
}

33
lib/utils.ts Normal file
View File

@ -0,0 +1,33 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Tailwind className 合并工具 */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/** 日期格式化 */
export function formatDate(
date: Date | string | number,
format = 'YYYY-MM-DD',
): string {
const d = new Date(date);
const pad = (n: number): string => String(n).padStart(2, '0');
const map: Record<string, string> = {
YYYY: String(d.getFullYear()),
MM: pad(d.getMonth() + 1),
DD: pad(d.getDate()),
HH: pad(d.getHours()),
mm: pad(d.getMinutes()),
ss: pad(d.getSeconds()),
};
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (m) => map[m]);
}
/** 拼接上传图片绝对 URL */
export function resolveUploadUrl(p?: string | null): string {
if (!p) return '';
if (/^https?:\/\//i.test(p)) return p;
const base = process.env.NEXT_PUBLIC_UPLOAD_URL ?? 'http://localhost:3001';
return `${base.replace(/\/$/, '')}${p.startsWith('/') ? '' : '/'}${p}`;
}

35
middleware.ts Normal file
View File

@ -0,0 +1,35 @@
import { NextResponse, type NextRequest } from 'next/server';
const TOKEN_COOKIE = process.env.NEXT_PUBLIC_TOKEN_KEY ?? 'admin_token';
/**
* Edge
* - /admin/login
* - /admin token cookie
*
* token adminStore cookie
* "是否已登录" JWT
*/
export function middleware(req: NextRequest): NextResponse {
const { pathname, search } = req.nextUrl;
if (!pathname.startsWith('/admin')) {
return NextResponse.next();
}
if (pathname === '/admin/login') {
return NextResponse.next();
}
const token = req.cookies.get(TOKEN_COOKIE)?.value;
if (!token) {
const url = req.nextUrl.clone();
url.pathname = '/admin/login';
url.searchParams.set('redirect', encodeURIComponent(pathname + search));
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*'],
};

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

15
next.config.js Normal file
View File

@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
// 后端服务端口由 server/.env 决定,默认允许本机后端与常见图片占位服务
remotePatterns: [
{ protocol: 'http', hostname: 'localhost', port: '3000' },
{ protocol: 'http', hostname: 'localhost', port: '3001' },
{ protocol: 'https', hostname: 'picsum.photos' },
{ protocol: 'https', hostname: '**' },
],
},
};
module.exports = nextConfig;

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "corp-official-website-client",
"version": "1.0.0",
"description": "corp-official-website Next.js 14 client",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint",
"format": "prettier --write \"app/**/*.{ts,tsx}\" \"components/**/*.{ts,tsx}\" \"lib/**/*.{ts,tsx}\""
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"axios": "^1.6.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-react": "^0.292.0",
"next": "14.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-markdown": "^10.1.0",
"react-quill": "^2.0.0",
"swr": "^2.2.4",
"tailwind-merge": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/node": "^20.9.2",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.54.0",
"eslint-config-next": "14.0.3",
"postcss": "^8.4.31",
"prettier": "^3.1.0",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2"
}
}

5435
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
public/customer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

BIN
public/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

BIN
public/staff.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

60
store/adminStore.ts Normal file
View File

@ -0,0 +1,60 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { AdminInfo } from '@/lib/types';
const TOKEN_COOKIE = process.env.NEXT_PUBLIC_TOKEN_KEY ?? 'admin_token';
const TOKEN_LS_KEY = TOKEN_COOKIE; // 文档要求JWT 仅前端 localStorage 存储
/** 把 token 同步到 Cookie便于 Edge middleware 在 SSR 前拦截 */
function syncCookie(token: string | null): void {
if (typeof document === 'undefined') return;
if (token) {
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `${TOKEN_COOKIE}=${encodeURIComponent(token)}; expires=${expires}; path=/; SameSite=Lax`;
} else {
document.cookie = `${TOKEN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;`;
}
}
/** 同步到 localStorageaxios 拦截器从这里取 */
function syncLocalStorage(token: string | null): void {
if (typeof window === 'undefined') return;
if (token) {
window.localStorage.setItem(TOKEN_LS_KEY, token);
} else {
window.localStorage.removeItem(TOKEN_LS_KEY);
}
}
interface AdminState {
token: string | null;
admin: AdminInfo | null;
setLogin: (token: string, admin: AdminInfo) => void;
logout: () => void;
hasAuth: () => boolean;
}
export const useAdminStore = create<AdminState>()(
persist(
(set, get) => ({
token: null,
admin: null,
setLogin: (token, admin) => {
syncLocalStorage(token);
syncCookie(token);
set({ token, admin });
},
logout: () => {
syncLocalStorage(null);
syncCookie(null);
set({ token: null, admin: null });
},
hasAuth: () => Boolean(get().token),
}),
{
name: 'corp_admin_session',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ token: s.token, admin: s.admin }),
},
),
);

59
tailwind.config.js Normal file
View File

@ -0,0 +1,59 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: '1rem',
screens: {
'2xl': '1280px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
brand: {
DEFAULT: '#4f46e5',
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
},
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"noImplicitAny": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long