添加文章管理和文章分组页面

This commit is contained in:
Eamon
2026-06-09 16:37:40 +08:00
parent 9f74483345
commit d2c7846a91
24 changed files with 1908 additions and 32 deletions

View File

@@ -112,7 +112,7 @@ export default function DashboardPage() {
// 处理取消
const handleCancel = () => {
setShowCustomPicker(false)
setTimeRange("7d")
setTimeRange("7d")
}
return (
@@ -232,18 +232,18 @@ export default function DashboardPage() {
{/* 点击自定义时显示 */}
{showCustomPicker && (
<div className="flex gap-2 items-center p-1 rounded-md">
<input
type="date"
<input
type="date"
className="px-2 py-1 border rounded text-sm bg-background"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
onChange={e => setStartDate(e.target.value)}
/>
<span className="text-muted-foreground">~</span>
<input
type="date"
<input
type="date"
className="px-2 py-1 border rounded text-sm bg-background"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
onChange={e => setEndDate(e.target.value)}
/>
<button
onClick={handleDateConfirm}
@@ -436,4 +436,4 @@ function ResourceItem({
</div>
</div>
)
}
}

View File

@@ -10,9 +10,11 @@ import {
DollarSign,
DoorClosedIcon,
FolderCode,
FolderTree,
Home,
KeyRound,
type LucideIcon,
Newspaper,
Package,
ScanSearch,
Shield,
@@ -217,6 +219,16 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [
label: "产品管理",
requiredScope: ScopeProductRead,
},
{
href: "/articles",
icon: Newspaper,
label: "文章管理",
},
{
href: "/article-groups",
icon: FolderTree,
label: "文章分组",
},
{
href: "/discount",
icon: SquarePercent,

View File

@@ -59,6 +59,7 @@ export default function Appbar(props: { admin: Admin }) {
dashboard: "控制台",
content: "内容管理",
articles: "文章管理",
"article-groups": "文章分组",
media: "媒体库",
user: "客户认领",
roles: "角色权限",
@@ -82,9 +83,12 @@ export default function Appbar(props: { admin: Admin }) {
balance: "余额明细",
gateway: "网关列表",
couponList: "已发放优惠券",
new: "新建文章",
}
return labels[path] || path
if (labels[path]) return labels[path]
if (/^\d+$/.test(path)) return "编辑文章"
return path
}
const breadcrumbs = generateBreadcrumbs()

View File

@@ -0,0 +1,208 @@
"use client"
import { Loader2 } from "lucide-react"
import { Suspense, useCallback, useState } from "react"
import { toast } from "sonner"
import {
createArticleGroup,
getArticleGroupPage,
removeArticleGroup,
updateArticleGroup,
} from "@/actions/article-group"
import { DataTable, useDataTable } from "@/components/data-table"
import { Page } from "@/components/page"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { ArticleGroup } from "@/models/article-group"
import { formatDate } from "@/models/formatDate"
export default function ArticleGroupPage() {
const fetchFn = useCallback(
(page: number, size: number) => getArticleGroupPage({ page, size }),
[],
)
const table = useDataTable(fetchFn)
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<ArticleGroup | null>(null)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [sort, setSort] = useState("0")
const [saving, setSaving] = useState(false)
const openCreate = () => {
setEditing(null)
setCode("")
setName("")
setSort("0")
setDialogOpen(true)
}
const openEdit = (group: ArticleGroup) => {
setEditing(group)
setCode(group.code)
setName(group.name)
setSort(String(group.sort))
setDialogOpen(true)
}
const handleSave = useCallback(async () => {
if (!code.trim() || !name.trim()) return
setSaving(true)
try {
if (editing) {
const resp = await updateArticleGroup({
id: editing.id,
code: code.trim(),
name: name.trim(),
sort: Number(sort),
})
if (resp.success) {
toast.success("分组更新成功")
setDialogOpen(false)
table.refresh()
} else {
toast.error(resp.message || "更新失败")
}
} else {
const resp = await createArticleGroup({
code: code.trim(),
name: name.trim(),
sort: Number(sort),
})
if (resp.success) {
toast.success("分组创建成功")
setDialogOpen(false)
table.refresh()
} else {
toast.error(resp.message || "创建失败")
}
}
} finally {
setSaving(false)
}
}, [code, name, sort, editing, table])
const handleDelete = useCallback(
async (id: number) => {
if (!confirm("确定要删除该分组吗?")) return
const resp = await removeArticleGroup(id)
if (resp.success) {
toast.success("分组已删除")
table.refresh()
} else {
toast.error(resp.message || "删除失败")
}
},
[table],
)
return (
<Page>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<Button onClick={openCreate}></Button>
</div>
<Suspense>
<DataTable<ArticleGroup>
{...table}
columns={[
{ header: "编码", accessorKey: "code" },
{ header: "名称", accessorKey: "name" },
{ header: "排序", accessorKey: "sort" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) => formatDate(row.original.created_at),
},
{
header: "操作",
id: "actions",
cell: ({ row }) => (
<div className="flex gap-2">
<Button
variant="link"
size="sm"
onClick={() => openEdit(row.original)}
>
</Button>
<Button
variant="link"
size="sm"
className="text-destructive"
onClick={() => handleDelete(row.original.id)}
>
</Button>
</div>
),
},
]}
/>
</Suspense>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "编辑分组" : "新建分组"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label></Label>
<Input
value={code}
onChange={e => setCode(e.target.value)}
placeholder="分组编码,如 news"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder="分组名称"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
type="number"
value={sort}
onChange={e => setSort(e.target.value)}
placeholder="0"
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
"保存"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Page>
)
}

View File

@@ -0,0 +1,135 @@
"use client"
import { Loader2 } from "lucide-react"
import dynamic from "next/dynamic"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { createArticle, getArticle, updateArticle } from "@/actions/article"
import type { ArticleGroup } from "@/models/article-group"
const Editor = dynamic(() => import("@/components/editor/richTextEditor"), {
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-64 text-muted-foreground">
...
</div>
),
})
interface ArticleEditorProps {
articleId: string
groups: ArticleGroup[]
}
export default function ArticleEditor({
articleId,
groups,
}: ArticleEditorProps) {
const router = useRouter()
const isNew = articleId === "new"
const [loading, setLoading] = useState(!isNew)
const [title, setTitle] = useState("")
const [initialContent, setInitialContent] = useState("")
const [groupId, setGroupId] = useState<string>(
groups.length > 0 ? String(groups[0].id) : "",
)
useEffect(() => {
if (!isNew) {
const id = Number(articleId)
if (Number.isNaN(id)) {
toast.error("无效的文章 ID")
router.push("/articles")
return
}
getArticle(id).then(resp => {
if (resp.success && resp.data) {
setTitle(resp.data.title || "")
setInitialContent(resp.data.content || "")
if (resp.data.group_id) {
setGroupId(String(resp.data.group_id))
}
} else {
toast.error(
resp.success ? "文章数据为空" : resp.message || "文章不存在",
)
router.push("/articles")
}
setLoading(false)
})
}
}, [articleId, isNew, router])
const handleSave = useCallback(
async (data: { title: string; content: string }) => {
if (!groupId) {
toast.error("请选择文章分组")
return
}
if (isNew) {
const resp = await createArticle({
title: data.title,
content: data.content,
group_id: Number(groupId),
status: 1,
})
if (resp.success) {
toast.success("文章创建成功")
router.push("/articles")
} else {
toast.error(resp.message || "创建失败")
}
} else {
const id = Number(articleId)
const resp = await updateArticle({
id,
title: data.title,
content: data.content,
group_id: Number(groupId),
status: 1,
})
if (resp.success) {
toast.success("文章保存成功")
router.push("/articles")
} else {
toast.error(resp.message || "保存失败")
}
}
},
[isNew, articleId, groupId, router],
)
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
if (groups.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
<p></p>
<Link href="/article-groups">
<span className="text-primary hover:underline"></span>
</Link>
</div>
)
}
return (
<div className="flex-1 h-full min-h-0">
<Editor
content={initialContent}
title={title}
groups={groups}
groupId={groupId}
onGroupChange={setGroupId}
onSave={handleSave}
/>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { getArticleGroupList } from "@/actions/article-group"
import { Page } from "@/components/page"
import ArticleEditor from "./article-editor"
export default async function ArticleEditPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const groupsResp = await getArticleGroupList()
console.log(groupsResp, "groupsResp")
const groups = groupsResp.success ? groupsResp.data : []
console.log(groups, "groups")
return (
<Page>
<ArticleEditor articleId={id} groups={groups} />
</Page>
)
}

View File

@@ -0,0 +1,105 @@
"use client"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Suspense, useCallback } from "react"
import { toast } from "sonner"
import { getArticlePage, removeArticle } from "@/actions/article"
import { DataTable, useDataTable } from "@/components/data-table"
import { Page } from "@/components/page"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import type { Article } from "@/models/article"
import { formatDate } from "@/models/formatDate"
export default function ArticleListPage() {
const router = useRouter()
const fetchFn = useCallback(
(page: number, size: number) => getArticlePage({ page, size }),
[],
)
const table = useDataTable(fetchFn)
console.log(table, "tabletabletable")
const handleDelete = useCallback(
async (id: number, title: string) => {
if (!confirm(`确定要删除文章《${title}》吗?此操作不可恢复!`)) return
try {
const resp = await removeArticle(id)
console.log(resp, "resprespresp")
if (resp.success) {
toast.success("文章已删除")
table.refresh()
router.refresh()
} else {
toast.error(resp.message || "删除失败")
}
} catch (error) {
toast.error("删除失败")
console.error(error)
}
},
[table, router],
)
return (
<Page>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<Link href="/articles/new">
<Button></Button>
</Link>
</div>
<Suspense>
<DataTable<Article>
{...table}
columns={[
{ header: "标题", accessorKey: "title" },
{
header: "状态",
accessorKey: "status",
cell: ({ row }) => (
<Badge
variant={row.original.status === 1 ? "default" : "secondary"}
>
{row.original.status === 1 ? "已发布" : "草稿"}
</Badge>
),
},
{
header: "更新时间",
accessorKey: "updated_at",
cell: ({ row }) => formatDate(row.original.updated_at),
},
{
header: "操作",
id: "actions",
cell: ({ row }) => (
<div className="flex gap-2">
<Link
href={`/articles/${row.original.id}`}
className="text-primary hover:underline"
>
</Link>
<button
onClick={() =>
handleDelete(row.original.id, row.original.title)
}
className="text-destructive hover:underline"
>
</button>
</div>
),
},
]}
/>
</Suspense>
</Page>
)
}

View File

@@ -1,5 +1,6 @@
"use client"
import { useSetAtom } from "jotai"
import { useEffect } from "react"
import { scopesAtom } from "@/lib/stores/scopes"
import type { Admin } from "@/models/admin"
@@ -8,7 +9,9 @@ export default function SetScopes(props: {
}) {
const setScopes = useSetAtom(scopesAtom)
console.log("用户权限", props.admin.scopes)
setScopes(props.admin.scopes)
useEffect(() => {
setScopes(props.admin.scopes)
}, [props.admin.scopes, setScopes])
return null
}

View File

@@ -122,3 +122,127 @@
@apply bg-background text-foreground;
}
}
.tiptap-editor {
.ProseMirror {
@apply w-full outline-none p-4;
h1 {
@apply text-2xl font-bold mt-4 mb-2;
}
h2 {
@apply text-xl font-semibold mt-3 mb-2;
}
h3 {
@apply text-lg font-medium mt-2 mb-1;
}
p {
@apply my-2 leading-7;
}
ul {
@apply list-disc pl-6 my-2;
}
ol {
@apply list-decimal pl-6 my-2;
}
li {
@apply my-1;
}
blockquote {
@apply border-l-4 border-muted pl-4 italic text-muted-foreground my-3;
}
code {
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-rose-600;
}
pre {
@apply bg-slate-900 text-slate-100 rounded-lg p-4 my-3 overflow-x-auto;
code {
@apply bg-transparent p-0 text-sm text-inherit;
}
}
hr {
@apply my-4 border-border;
}
a {
@apply text-blue-600 underline;
}
strong {
@apply font-semibold;
}
em {
@apply italic;
}
s {
@apply line-through;
}
ul[data-type="taskList"] {
@apply list-none pl-0;
li {
@apply flex items-start gap-2 my-1;
label {
@apply mt-1;
}
div {
@apply flex-1;
}
}
}
table {
@apply border-collapse w-full my-3;
th,
td {
@apply border border-border px-3 py-2 text-sm;
}
th {
@apply bg-muted font-semibold text-left;
}
}
}
}
/* Code block syntax highlighting */
.tiptap-editor pre code .hljs-keyword,
.tiptap-editor pre code .hljs-selector-tag,
.tiptap-editor pre code .hljs-type {
color: #c792ea;
}
.tiptap-editor pre code .hljs-string,
.tiptap-editor pre code .hljs-attr {
color: #c3e88d;
}
.tiptap-editor pre code .hljs-number,
.tiptap-editor pre code .hljs-literal {
color: #f78c6c;
}
.tiptap-editor pre code .hljs-comment {
color: #676e95;
font-style: italic;
}
.tiptap-editor pre code .hljs-title,
.tiptap-editor pre code .hljs-function,
.tiptap-editor pre code .hljs-name {
color: #82aaff;
}
.tiptap-editor pre code .hljs-built_in,
.tiptap-editor pre code .hljs-symbol {
color: #ffcb6b;
}
.tiptap-editor pre code .hljs-variable,
.tiptap-editor pre code .hljs-params {
color: #f07178;
}
.tiptap-editor pre code .hljs-meta,
.tiptap-editor pre code .hljs-tag {
color: #89ddff;
}
.tiptap-editor pre code .hljs-attr {
color: #c3e88d;
}