fix:初始化
This commit is contained in:
parent
eaf3ce327e
commit
9003d8718a
6
.env.production
Normal file
6
.env.production
Normal 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
7
.eslintrc.json
Normal 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
88
.gitea/workflows/main.yml
Normal 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
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
24
app/(front)/about/page.tsx
Normal file
24
app/(front)/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
app/(front)/contact/page.tsx
Normal file
54
app/(front)/contact/page.tsx
Normal 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
29
app/(front)/layout.tsx
Normal 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; // ISR:60 秒重新生成
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
34
app/(front)/manual/[id]/page.tsx
Normal file
34
app/(front)/manual/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
36
app/(front)/manual/page.tsx
Normal file
36
app/(front)/manual/page.tsx
Normal 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} />;
|
||||
}
|
||||
72
app/(front)/news/[id]/page.tsx
Normal file
72
app/(front)/news/[id]/page.tsx
Normal 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
98
app/(front)/news/page.tsx
Normal 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
90
app/(front)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
app/(front)/products/[id]/page.tsx
Normal file
68
app/(front)/products/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
app/(front)/products/page.tsx
Normal file
133
app/(front)/products/page.tsx
Normal 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
48
app/(front)/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
568
app/admin/admin-user/page.tsx
Normal file
568
app/admin/admin-user/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
app/admin/dashboard/page.tsx
Normal file
239
app/admin/dashboard/page.tsx
Normal 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
11
app/admin/layout.tsx
Normal 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
16
app/admin/loading.tsx
Normal 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
206
app/admin/login/page.tsx
Normal 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">懂物业,更懂“省”心</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
511
app/admin/manual/page.tsx
Normal 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
208
app/admin/message/page.tsx
Normal 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
519
app/admin/news/page.tsx
Normal 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
5
app/admin/page.tsx
Normal 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
516
app/admin/product/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
317
app/admin/site-config/page.tsx
Normal file
317
app/admin/site-config/page.tsx
Normal 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
336
app/admin/team/page.tsx
Normal 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
116
app/globals.css
Normal 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
42
app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
components/admin/AdminHeader.tsx
Normal file
57
components/admin/AdminHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
components/admin/AdminShell.tsx
Normal file
31
components/admin/AdminShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
components/admin/AdminSidebar.tsx
Normal file
111
components/admin/AdminSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
components/admin/Avatar.tsx
Normal file
46
components/admin/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
components/admin/ImageUpload.tsx
Normal file
127
components/admin/ImageUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
components/admin/MarkdownImport.tsx
Normal file
82
components/admin/MarkdownImport.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
components/admin/PaginationTable.tsx
Normal file
133
components/admin/PaginationTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
components/admin/RichEditor.tsx
Normal file
92
components/admin/RichEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
components/admin/TableToolbar.tsx
Normal file
31
components/admin/TableToolbar.tsx
Normal 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
115
components/admin/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
components/front/BannerCarousel.tsx
Normal file
121
components/front/BannerCarousel.tsx
Normal 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">
|
||||
懂物业,更懂“省”心。一站式物业管理平台,覆盖缴费、报修、公告、巡检全流程。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
158
components/front/ContactForm.tsx
Normal file
158
components/front/ContactForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
components/front/ContentView.tsx
Normal file
82
components/front/ContentView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
components/front/CtaSection.tsx
Normal file
41
components/front/CtaSection.tsx
Normal 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">
|
||||
懂物业,更懂“省”心。立即预约演示,体验智慧物业管理的便捷与高效。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
129
components/front/FeatureGrid.tsx
Normal file
129
components/front/FeatureGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
components/front/Footer.tsx
Normal file
53
components/front/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
components/front/Header.tsx
Normal file
92
components/front/Header.tsx
Normal 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
186
components/front/Hero.tsx
Normal 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">
|
||||
懂物业,更懂“省”心
|
||||
</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>
|
||||
);
|
||||
}
|
||||
130
components/front/ManualLayout.tsx
Normal file
130
components/front/ManualLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
components/front/ManualTreeNav.tsx
Normal file
95
components/front/ManualTreeNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
components/front/NewsCard.tsx
Normal file
44
components/front/NewsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/front/ProductCard.tsx
Normal file
36
components/front/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
components/front/Section.tsx
Normal file
47
components/front/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
components/front/SolutionShowcase.tsx
Normal file
113
components/front/SolutionShowcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
components/front/StatsBar.tsx
Normal file
26
components/front/StatsBar.tsx
Normal 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
32
components/ui/badge.tsx
Normal 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
55
components/ui/button.tsx
Normal 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
77
components/ui/card.tsx
Normal 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
104
components/ui/dialog.tsx
Normal 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
25
components/ui/input.tsx
Normal 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
22
components/ui/label.tsx
Normal 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
27
components/ui/select.tsx
Normal 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
80
components/ui/table.tsx
Normal 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 };
|
||||
24
components/ui/textarea.tsx
Normal file
24
components/ui/textarea.tsx
Normal 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
135
lib/admin-services.ts
Normal 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
98
lib/api.ts
Normal 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
15
lib/content.ts
Normal 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
48
lib/services.ts
Normal 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
152
lib/types.ts
Normal 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
33
lib/utils.ts
Normal 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
35
middleware.ts
Normal 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
5
next-env.d.ts
vendored
Normal 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
15
next.config.js
Normal 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
49
package.json
Normal 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
5435
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
public/customer.png
Normal file
BIN
public/customer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 622 KiB |
BIN
public/dashboard.png
Normal file
BIN
public/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
BIN
public/staff.png
Normal file
BIN
public/staff.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
60
store/adminStore.ts
Normal file
60
store/adminStore.ts
Normal 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=/;`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步到 localStorage,axios 拦截器从这里取 */
|
||||
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
59
tailwind.config.js
Normal 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
25
tsconfig.json
Normal 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
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user