From 9003d8718ad51a1f7764b38a1e1c4543f4d36640 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 22 Jun 2026 14:43:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.production | 6 + .eslintrc.json | 7 + .gitea/workflows/main.yml | 88 + .gitignore | 5 + .prettierrc | 7 + app/(front)/about/page.tsx | 24 + app/(front)/contact/page.tsx | 54 + app/(front)/layout.tsx | 29 + app/(front)/manual/[id]/page.tsx | 34 + app/(front)/manual/page.tsx | 36 + app/(front)/news/[id]/page.tsx | 72 + app/(front)/news/page.tsx | 98 + app/(front)/page.tsx | 90 + app/(front)/products/[id]/page.tsx | 68 + app/(front)/products/page.tsx | 133 + app/(front)/team/page.tsx | 48 + app/admin/admin-user/page.tsx | 568 +++ app/admin/dashboard/page.tsx | 239 ++ app/admin/layout.tsx | 11 + app/admin/loading.tsx | 16 + app/admin/login/page.tsx | 206 + app/admin/manual/page.tsx | 511 +++ app/admin/message/page.tsx | 208 + app/admin/news/page.tsx | 519 +++ app/admin/page.tsx | 5 + app/admin/product/page.tsx | 516 +++ app/admin/site-config/page.tsx | 317 ++ app/admin/team/page.tsx | 336 ++ app/globals.css | 116 + app/layout.tsx | 42 + components/admin/AdminHeader.tsx | 57 + components/admin/AdminShell.tsx | 31 + components/admin/AdminSidebar.tsx | 111 + components/admin/Avatar.tsx | 46 + components/admin/ImageUpload.tsx | 127 + components/admin/MarkdownImport.tsx | 82 + components/admin/PaginationTable.tsx | 133 + components/admin/RichEditor.tsx | 92 + components/admin/TableToolbar.tsx | 31 + components/admin/TopBar.tsx | 115 + components/front/BannerCarousel.tsx | 121 + components/front/ContactForm.tsx | 158 + components/front/ContentView.tsx | 82 + components/front/CtaSection.tsx | 41 + components/front/FeatureGrid.tsx | 129 + components/front/Footer.tsx | 53 + components/front/Header.tsx | 92 + components/front/Hero.tsx | 186 + components/front/ManualLayout.tsx | 130 + components/front/ManualTreeNav.tsx | 95 + components/front/NewsCard.tsx | 44 + components/front/ProductCard.tsx | 36 + components/front/Section.tsx | 47 + components/front/SolutionShowcase.tsx | 113 + components/front/StatsBar.tsx | 26 + components/ui/badge.tsx | 32 + components/ui/button.tsx | 55 + components/ui/card.tsx | 77 + components/ui/dialog.tsx | 104 + components/ui/input.tsx | 25 + components/ui/label.tsx | 22 + components/ui/select.tsx | 27 + components/ui/table.tsx | 80 + components/ui/textarea.tsx | 24 + lib/admin-services.ts | 135 + lib/api.ts | 98 + lib/content.ts | 15 + lib/services.ts | 48 + lib/types.ts | 152 + lib/utils.ts | 33 + middleware.ts | 35 + next-env.d.ts | 5 + next.config.js | 15 + package.json | 49 + pnpm-lock.yaml | 5435 +++++++++++++++++++++++++ postcss.config.js | 6 + public/customer.png | Bin 0 -> 637255 bytes public/dashboard.png | Bin 0 -> 465972 bytes public/staff.png | Bin 0 -> 173003 bytes store/adminStore.ts | 60 + tailwind.config.js | 59 + tsconfig.json | 25 + tsconfig.tsbuildinfo | 1 + 83 files changed, 13204 insertions(+) create mode 100644 .env.production create mode 100644 .eslintrc.json create mode 100644 .gitea/workflows/main.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 app/(front)/about/page.tsx create mode 100644 app/(front)/contact/page.tsx create mode 100644 app/(front)/layout.tsx create mode 100644 app/(front)/manual/[id]/page.tsx create mode 100644 app/(front)/manual/page.tsx create mode 100644 app/(front)/news/[id]/page.tsx create mode 100644 app/(front)/news/page.tsx create mode 100644 app/(front)/page.tsx create mode 100644 app/(front)/products/[id]/page.tsx create mode 100644 app/(front)/products/page.tsx create mode 100644 app/(front)/team/page.tsx create mode 100644 app/admin/admin-user/page.tsx create mode 100644 app/admin/dashboard/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/loading.tsx create mode 100644 app/admin/login/page.tsx create mode 100644 app/admin/manual/page.tsx create mode 100644 app/admin/message/page.tsx create mode 100644 app/admin/news/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/product/page.tsx create mode 100644 app/admin/site-config/page.tsx create mode 100644 app/admin/team/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 components/admin/AdminHeader.tsx create mode 100644 components/admin/AdminShell.tsx create mode 100644 components/admin/AdminSidebar.tsx create mode 100644 components/admin/Avatar.tsx create mode 100644 components/admin/ImageUpload.tsx create mode 100644 components/admin/MarkdownImport.tsx create mode 100644 components/admin/PaginationTable.tsx create mode 100644 components/admin/RichEditor.tsx create mode 100644 components/admin/TableToolbar.tsx create mode 100644 components/admin/TopBar.tsx create mode 100644 components/front/BannerCarousel.tsx create mode 100644 components/front/ContactForm.tsx create mode 100644 components/front/ContentView.tsx create mode 100644 components/front/CtaSection.tsx create mode 100644 components/front/FeatureGrid.tsx create mode 100644 components/front/Footer.tsx create mode 100644 components/front/Header.tsx create mode 100644 components/front/Hero.tsx create mode 100644 components/front/ManualLayout.tsx create mode 100644 components/front/ManualTreeNav.tsx create mode 100644 components/front/NewsCard.tsx create mode 100644 components/front/ProductCard.tsx create mode 100644 components/front/Section.tsx create mode 100644 components/front/SolutionShowcase.tsx create mode 100644 components/front/StatsBar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 lib/admin-services.ts create mode 100644 lib/api.ts create mode 100644 lib/content.ts create mode 100644 lib/services.ts create mode 100644 lib/types.ts create mode 100644 lib/utils.ts create mode 100644 middleware.ts create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.js create mode 100644 public/customer.png create mode 100644 public/dashboard.png create mode 100644 public/staff.png create mode 100644 store/adminStore.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..dde215c --- /dev/null +++ b/.env.production @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..dc55cc0 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["next/core-web-vitals"], + "rules": { + "@typescript-eslint/no-explicit-any": "error", + "react/no-unescaped-entities": "off" + } +} diff --git a/.gitea/workflows/main.yml b/.gitea/workflows/main.yml new file mode 100644 index 0000000..e5df906 --- /dev/null +++ b/.gitea/workflows/main.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87bc58a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.next/ +.env +.env.local +*.log diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8b0e83d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "printWidth": 100, + "tabWidth": 2 +} diff --git a/app/(front)/about/page.tsx b/app/(front)/about/page.tsx new file mode 100644 index 0000000..755e3bc --- /dev/null +++ b/app/(front)/about/page.tsx @@ -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 ( +
+

+ {config?.aboutTitle || '关于我们'} +

+
+
+ ); +} diff --git a/app/(front)/contact/page.tsx b/app/(front)/contact/page.tsx new file mode 100644 index 0000000..b5ca3b1 --- /dev/null +++ b/app/(front)/contact/page.tsx @@ -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 ( +
+

联系我们

+

+ 我们期待您的来访与咨询 +

+ +
+
+

联系方式

+
    + {config?.tel && ( +
  • +
    客服电话
    +
    {config.tel}
    +
  • + )} + {config?.email && ( +
  • +
    商务邮箱
    +
    {config.email}
    +
  • + )} + {config?.address && ( +
  • +
    公司地址
    +
    {config.address}
    +
  • + )} +
+
+
+

在线留言

+
+ +
+
+
+
+ ); +} diff --git a/app/(front)/layout.tsx b/app/(front)/layout.tsx new file mode 100644 index 0000000..113f777 --- /dev/null +++ b/app/(front)/layout.tsx @@ -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 ( +
+
+
{children}
+
+
+ ); +} diff --git a/app/(front)/manual/[id]/page.tsx b/app/(front)/manual/[id]/page.tsx new file mode 100644 index 0000000..83060f7 --- /dev/null +++ b/app/(front)/manual/[id]/page.tsx @@ -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 { + 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 ; +} diff --git a/app/(front)/manual/page.tsx b/app/(front)/manual/page.tsx new file mode 100644 index 0000000..3d273b3 --- /dev/null +++ b/app/(front)/manual/page.tsx @@ -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 ; +} diff --git a/app/(front)/news/[id]/page.tsx b/app/(front)/news/[id]/page.tsx new file mode 100644 index 0000000..a049b4f --- /dev/null +++ b/app/(front)/news/[id]/page.tsx @@ -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 { + 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 ( +
+ + +
+

+ {news.title} +

+
+ {news.category?.name && ( + + {news.category.name} + + )} + 发布时间:{formatDate(news.createdAt, 'YYYY-MM-DD HH:mm')} + {news.isTop === 1 && ( + 置顶 + )} +
+
+ + {news.cover && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {news.title} +
+ )} + + +
+ ); +} diff --git a/app/(front)/news/page.tsx b/app/(front)/news/page.tsx new file mode 100644 index 0000000..62e9cdc --- /dev/null +++ b/app/(front)/news/page.tsx @@ -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 ( +
+

新闻资讯

+

共 {newsRes.total} 条

+ +
+ + 全部 + + {categories.map((c) => ( + + {c.name} + + ))} +
+ +
+ {newsRes.list.map((n) => ( + + ))} +
+ {newsRes.list.length === 0 && ( +
+ 暂无新闻 +
+ )} + + {newsRes.total > 10 && ( + + )} +
+ ); +} diff --git a/app/(front)/page.tsx b/app/(front)/page.tsx new file mode 100644 index 0000000..ce9ad64 --- /dev/null +++ b/app/(front)/page.tsx @@ -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 ( + <> + + + {/* 数据条 */} + + + {/* 核心功能 */} + + + {/* 解决方案 */} + + + {/* 产品 */} +
+
+ {products.map((p) => ( + + ))} +
+ {products.length === 0 && } +
+ + {/* 新闻 */} +
+
+ {news.map((n) => ( + + ))} +
+ {news.length === 0 && } +
+ + {/* 转化引导 */} + + + ); +} + +function Empty({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} diff --git a/app/(front)/products/[id]/page.tsx b/app/(front)/products/[id]/page.tsx new file mode 100644 index 0000000..83905a0 --- /dev/null +++ b/app/(front)/products/[id]/page.tsx @@ -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 { + 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 ( +
+ + +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {product.name} +
+
+

{product.name}

+ {product.category?.name && ( + + {product.category.name} + + )} + {product.desc && ( +

{product.desc}

+ )} +
+
+ + {product.content && ( + + )} +
+ ); +} diff --git a/app/(front)/products/page.tsx b/app/(front)/products/page.tsx new file mode 100644 index 0000000..43f496d --- /dev/null +++ b/app/(front)/products/page.tsx @@ -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 ( +
+

产品中心

+

+ 共 {productsRes.total} 款产品 +

+ + {/* 分类筛选 */} +
+ + 全部 + + {categories.map((c) => ( + + {c.name} + + ))} +
+ + {/* 列表 */} +
+ {productsRes.list.map((p) => ( + + ))} +
+ {productsRes.list.length === 0 && ( +
+ 暂无产品 +
+ )} + + {/* 分页 */} + {productsRes.total > 12 && ( + + )} +
+ ); +} + +function Pagination({ + page, + total, + pageSize, + baseQuery, +}: { + page: number; + total: number; + pageSize: number; + baseQuery: Record; +}) { + const totalPages = Math.ceil(total / pageSize); + const buildHref = (p: number) => { + const qs = new URLSearchParams({ + ...(baseQuery as Record), + page: String(p), + }).toString(); + return `/products?${qs}`; + }; + return ( + + ); +} diff --git a/app/(front)/team/page.tsx b/app/(front)/team/page.tsx new file mode 100644 index 0000000..fe05418 --- /dev/null +++ b/app/(front)/team/page.tsx @@ -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 ( +
+

团队介绍

+

+ 由经验丰富的专业人士组成 +

+
+ {team.map((m) => ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {m.name} +
+

+ {m.name} +

+

{m.position}

+ {m.desc && ( +

{m.desc}

+ )} +
+ ))} +
+ {team.length === 0 && ( +
+ 暂无成员 +
+ )} +
+ ); +} diff --git a/app/admin/admin-user/page.tsx b/app/admin/admin-user/page.tsx new file mode 100644 index 0000000..bbe81cc --- /dev/null +++ b/app/admin/admin-user/page.tsx @@ -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 = { + 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(null); + const [createForm, setCreateForm] = useState(CREATE_DEFAULT); + const [editForm, setEditForm] = useState({ + nickname: '', + avatar: '', + role: 'normal', + }); + const [saving, setSaving] = useState(false); + + // 重置密码 + const [pwdOpen, setPwdOpen] = useState(false); + const [pwdId, setPwdId] = useState(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[] = [ + { key: 'id', title: 'ID', width: 60 }, + { + key: 'avatar', + title: '头像', + width: 80, + render: (r) => , + }, + { + key: 'username', + title: '登录账号', + width: 200, + render: (r) => ( +
+ {r.username} + {r.id === currentAdminId && ( + + 当前 + + )} +
+ ), + }, + { key: 'nickname', title: '名称', width: 140 }, + { + key: 'role', + title: '角色', + width: 130, + render: (r) => + r.role === 'super_admin' ? ( + + {ROLE_LABEL[r.role]} + + ) : ( + {ROLE_LABEL[r.role]} + ), + }, + { + key: 'createdAt', + title: '创建时间', + width: 160, + render: (r) => formatDate(r.createdAt, 'YYYY-MM-DD HH:mm'), + }, + { + key: '_op', + title: '操作', + width: 240, + render: (r) => + isSuperAdmin ? ( +
+ + + {r.id !== currentAdminId && ( + + )} +
+ ) : ( + 无操作权限 + ), + }, + ]; + + return ( +
+ + + + setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setSearchKw(keyword); + setPage(1); + } + }} + className="max-w-xs" + /> + + + } + right={ + isSuperAdmin ? ( + + ) : null + } + /> + + + columns={columns} + rows={data?.list ?? []} + total={data?.total ?? 0} + page={page} + pageSize={10} + loading={isLoading} + onPageChange={setPage} + rowKey={(r) => String(r.id)} + /> + + {/* 创建 / 编辑弹窗 */} + + + + + {editId ? '编辑管理员账号' : '新增管理员账号'} + + +
+ {editId ? ( + <> +
+ + x.id === editId)?.username ?? '' + } + disabled + /> +

+ 登录账号创建后不可修改,如需修改请删除后重建 +

+
+
+ + + setEditForm({ ...editForm, nickname: e.target.value }) + } + /> +
+
+ + + setEditForm({ ...editForm, avatar: url }) + } + hint="建议正方形头像,单图 ≤ 2M" + /> +
+
+ +
+ + +
+ {editId === currentAdminId && ( +

+ 不能将自己降级为普通管理员 +

+ )} +
+ + ) : ( + <> +
+ + + setCreateForm({ + ...createForm, + username: e.target.value, + }) + } + /> +

+ 2-50 个字符,创建后不可修改 +

+
+
+ + + setCreateForm({ + ...createForm, + password: e.target.value, + }) + } + /> +

+ 创建后请通过安全渠道告知该员工 +

+
+
+ + + setCreateForm({ + ...createForm, + nickname: e.target.value, + }) + } + /> +
+
+ + + setCreateForm({ ...createForm, avatar: url }) + } + hint="可选,建议正方形头像,单图 ≤ 2M" + /> +
+
+ +
+ + +
+

+ 超级管理员可管理所有账号及删除内容 +

+
+ + )} +
+ + + + +
+
+ + {/* 重置密码弹窗 */} + + + + 重置密码 + +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setPwdConfirm(e.target.value)} + /> +
+

+ 重置后请通过安全渠道告知该员工 +

+
+ + + + +
+
+
+ ); +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..6a092ec --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -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( + hydrated ? 'dashboard:product-count' : null, + () => + adminApi.productPaginate({ page: 1, pageSize: 1 }).then((r) => r.total), + ); + const { data: newsCount } = useSWR( + hydrated ? 'dashboard:news-count' : null, + () => adminApi.newsPaginate({ page: 1, pageSize: 1 }).then((r) => r.total), + ); + const { data: teamCount } = useSWR( + 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: , + }, + { + href: '/admin/news', + label: '新闻资讯', + value: newsCount, + icon: , + }, + { + href: '/admin/team', + label: '团队成员', + value: teamCount, + icon: , + }, + { + href: '/admin/message', + label: '客户留言', + value: message?.total, + icon: , + badge: + message && message.unread > 0 ? `${message.unread} 未读` : undefined, + }, + ]; + + const quickActions = [ + { + href: '/admin/product', + label: '发布产品方案', + hint: '新增 / 编辑产品', + icon: , + }, + { + href: '/admin/news', + label: '发布新闻动态', + hint: '撰写行业资讯', + icon: , + }, + { + href: '/admin/team', + label: '维护团队', + hint: '成员信息', + icon: , + }, + { + href: '/admin/site-config', + label: '站点配置', + hint: '基础信息 / 联系方式', + icon: , + }, + ]; + + return ( +
+ + + {/* ===== 数据统计卡 ===== */} +
+ {stats.map((s) => ( + + ))} +
+ + {/* ===== 主体两栏 ===== */} +
+ {/* 快捷操作 */} +
+
+

快捷操作

+ 常用入口 +
+
+ {quickActions.map((a) => ( + + + {a.icon} + +
+
+ {a.label} +
+
{a.hint}
+
+ + + ))} +
+
+ + {/* 系统信息 / 访问地址 */} + +
+
+ ); +} + +function Stat({ + href, + label, + value, + icon, + badge, +}: { + href: string; + label: string; + value: number | undefined; + icon: React.ReactNode; + badge?: string; +}) { + return ( + + + +
+ + {icon} + + {badge ? ( + + {badge} + + ) : ( + + )} +
+
+ + {value ?? '—'} + +
+
{label}
+
+
+ + ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..781d095 --- /dev/null +++ b/app/admin/layout.tsx @@ -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 {children}; +} diff --git a/app/admin/loading.tsx b/app/admin/loading.tsx new file mode 100644 index 0000000..483f7d1 --- /dev/null +++ b/app/admin/loading.tsx @@ -0,0 +1,16 @@ +import { Loader2 } from 'lucide-react'; + +/** + * 后台路由级加载态(Next.js App Router Suspense fallback)。 + * 在页面 chunk 加载或服务端渲染期间,先展示居中 spinner,避免白屏/闪烁。 + */ +export default function AdminLoading() { + return ( +
+
+ + 加载中… +
+
+ ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..e8a804f --- /dev/null +++ b/app/admin/login/page.tsx @@ -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; + +export default function AdminLoginPage() { + const router = useRouter(); + const search = useSearchParams(); + const { setLogin, token } = useAdminStore(); + const [serverError, setServerError] = useState(null); + + // 已登录则直接跳到后台 + useEffect(() => { + if (token) { + const redirect = search.get('redirect'); + router.replace(redirect || '/admin/dashboard'); + } + }, [token, router, search]); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + 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 ( +
+ {/* ===== 左侧:品牌展示区(深色,呼应首页 Hero)===== */} +
+ {/* 渐变底 + 辉光 + 几何图形 */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* Logo */} +
+ + + + 智管物业 +
+ + {/* 标语 */} +
+

+ 让物业管理 +
+ + 像发微信一样简单 + +

+

懂物业,更懂“省”心

+ +
+ {[ + '一站式物业管理 SaaS 平台', + '覆盖缴费、报修、公告、巡检全流程', + '三端协同,助力物业降本增效', + ].map((t) => ( +
+ + {t} +
+ ))} +
+
+ + {/* 版权 */} +
+ © {new Date().getFullYear()} 智管物业 · 物业管理 SaaS 专家 +
+
+ + {/* ===== 右侧:表单区(干净浅色)===== */} +
+
+ {/* 移动端 Logo(左侧面板在小屏隐藏)*/} +
+ + + + 智管物业 +
+ +
+ + Admin + +

欢迎回来

+

登录智管物业管理后台

+
+ + {search.get('expired') && ( +
+ 登录已过期,请重新登录 +
+ )} + +
{ + e.preventDefault(); + void handleSubmit(onSubmit)(e); + }} + className="space-y-5" + > +
+ +
+ + +
+ {errors.username && ( +

{errors.username.message}

+ )} +
+ +
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ + {serverError && ( +

{serverError}

+ )} + + +
+ +

+ 初始账号:admin / 密码:123456 +

+
+
+
+ ); +} diff --git a/app/admin/manual/page.tsx b/app/admin/manual/page.tsx new file mode 100644 index 0000000..8bdddc7 --- /dev/null +++ b/app/admin/manual/page.tsx @@ -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(null); + const [saving, setSaving] = useState(false); + const [contentMode, setContentMode] = useState('rich'); + const [form, setForm] = useState(FORM_DEFAULT); + + const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin'; + + useEffect(() => setHydrated(true), []); + + const { data: tree, isLoading, mutate } = useSWR( + 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 = { + 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 ( +
+ + + + setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') setSearchKw(keyword); + }} + className="max-w-xs" + /> + + + } + right={ + + } + /> + +
+ + + + ID + 标题 / 层级 + 类型 + 状态 + 操作 + + + + {isLoading && ( + + +
+ + 加载中… +
+
+
+ )} + {!isLoading && rows.length === 0 && ( + + + 暂无数据,请新增节点 + + + )} + {rows.map((r) => ( + + {r.id} + +
+ {/* 缩进引导线:每层级一条垂直线 */} + {Array.from({ length: r._depth }).map((_, i) => ( + + + + ))} + {/* 节点图标:目录=琥珀色底,文档=灰色底 */} + + {r.type === 0 ? ( + + ) : ( + + )} + + + {r.title} + +
+
+ + {r.type === 0 ? ( + 目录 + ) : ( + 文档 + )} + + + {r.isShow === 1 ? ( + 显示 + ) : ( + 隐藏 + )} + + +
+ {r.type === 0 && ( + + )} + + {isSuperAdmin && ( + + )} +
+
+
+ ))} +
+
+
+ + {/* 新增 / 编辑节点 */} + + + + {editId ? '编辑节点' : '新增节点'} + +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + setForm({ ...form, title: e.target.value })} + placeholder={form.type === 0 ? '目录名称' : '文档标题'} + /> +
+ + {form.type === 1 && ( +
+
+ +
+
+ + +
+ {contentMode === 'markdown' && ( + setForm({ ...form, content: md })} + onError={(msg) => alert(msg)} + /> + )} +
+
+ {contentMode === 'rich' ? ( + setForm({ ...form, content: html })} + /> + ) : ( +