feat(后台公告): 公告列表一行一条,「编辑」常驻、置顶和删除收进 ⋯;改公告时标题、类型、置顶、学生看得到排一行,保存挪到右上角
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b93d4d83a1
commit
bd3a57468f
3 files changed
+173
-74
No files matched your search
@@ -1,14 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { deleteAnnouncement } from "admin/api"
|
||||
|
||||
/**
|
||||
* 公告一行的操作(设计稿「后台公告」AnnAdmin):「编辑」常驻,置顶 / 删除收进 ⋯
|
||||
*/
|
||||
interface Props {
|
||||
announcementID: number
|
||||
top: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
const emit = defineEmits<{ deleted: []; toggleTop: [] }>()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
@@ -17,21 +23,36 @@ function goEdit() {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteAnnouncement(props.announcementID)
|
||||
message.success("删除成功")
|
||||
emit("deleted")
|
||||
const menuOptions = computed<DropdownOption[]>(() => [
|
||||
{ label: props.top ? "取消置顶" : "置顶", key: "top" },
|
||||
{ type: "divider", key: "divider" },
|
||||
{ label: "删除", key: "delete" },
|
||||
])
|
||||
|
||||
function onMenu(key: string) {
|
||||
if (key === "top") emit("toggleTop")
|
||||
else if (key === "delete") {
|
||||
dialog.warning({
|
||||
title: "删除这条公告?",
|
||||
content: "删掉就找不回来了。只是不想让学生看到的话,关掉「学生看得到」就行。",
|
||||
positiveText: "删除",
|
||||
negativeText: "不删",
|
||||
onPositiveClick: async () => {
|
||||
await deleteAnnouncement(props.announcementID)
|
||||
message.success("删掉了")
|
||||
emit("deleted")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="success" secondary @click="goEdit"> 编辑 </n-button>
|
||||
<n-popconfirm @positive-click="handleDelete">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" secondary>删除</n-button>
|
||||
</template>
|
||||
确定删除这条公告吗?
|
||||
</n-popconfirm>
|
||||
<n-flex :size="4" justify="end" :wrap="false">
|
||||
<n-button size="small" @click="goEdit">编辑</n-button>
|
||||
<n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
|
||||
<n-button size="small" quaternary aria-label="更多">
|
||||
<template #icon><Icon icon="ph:dots-three-bold" /></template>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
@@ -60,9 +60,9 @@ async function submit() {
|
||||
try {
|
||||
await api!(announcement)
|
||||
if (route.name === "admin announcement create") {
|
||||
message.success("成功新建公告 💐")
|
||||
message.success("发好了")
|
||||
} else {
|
||||
message.success("修改已保存")
|
||||
message.success("改好了")
|
||||
}
|
||||
router.push({ name: "admin announcement list" })
|
||||
} catch (err) {
|
||||
@@ -74,34 +74,51 @@ onMounted(init)
|
||||
</script>
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="route.name === 'admin announcement create' ? '新建公告' : '编辑公告'"
|
||||
:title="route.name === 'admin announcement create' ? '写一条公告' : '改公告'"
|
||||
:back="{ name: 'admin announcement list' }"
|
||||
/>
|
||||
<n-form inline>
|
||||
<n-form-item label="标题">
|
||||
<n-input class="contestTitle" v-model:value="announcement.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标签">
|
||||
<n-select class="select" v-model:value="announcement.tag" :options="tags" />
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="announcement.visible" />
|
||||
</n-form-item>
|
||||
<n-form-item label="置顶">
|
||||
<n-switch v-model:value="announcement.top" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<TextEditor v-if="ready" title="正文" v-model:value="announcement.content" :min-height="200" />
|
||||
<n-flex style="margin-bottom: 100px" justify="end">
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</n-flex>
|
||||
>
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<!-- 标题、类型、置顶、学生看得到排一行;保存在右上角,不用滚到正文底下去点 -->
|
||||
<div class="meta">
|
||||
<n-input
|
||||
v-model:value="announcement.title"
|
||||
placeholder="标题"
|
||||
size="large"
|
||||
class="title"
|
||||
aria-label="标题"
|
||||
/>
|
||||
<n-radio-group v-model:value="announcement.tag" aria-label="类型">
|
||||
<n-radio-button v-for="t in tags" :key="String(t.value)" :value="t.value">
|
||||
{{ t.label }}
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
<label class="switch">置顶 <n-switch v-model:value="announcement.top" /></label>
|
||||
<label class="switch">学生看得到 <n-switch v-model:value="announcement.visible" /></label>
|
||||
</div>
|
||||
<TextEditor v-if="ready" title="正文" v-model:value="announcement.content" :min-height="320" />
|
||||
</template>
|
||||
<style scoped>
|
||||
.select {
|
||||
width: 100px;
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.contestTitle {
|
||||
width: 400px;
|
||||
.title {
|
||||
flex: 1 1 320px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { NSwitch, useThemeVars } from "naive-ui"
|
||||
import { RouterLink } from "vue-router"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminAnnouncementListItem } from "utils/types"
|
||||
@@ -15,57 +16,77 @@ const query = reactive({
|
||||
})
|
||||
const announcements = ref<AdminAnnouncementListItem[]>([])
|
||||
|
||||
/**
|
||||
* 后台公告列表(设计稿「后台公告」AnnAdmin):一行一条,标题(置顶挂标,公告 / 更新淡淡写在后面)、
|
||||
* 发布、改过、学生看得到;「编辑」常驻,置顶 / 删除收进 ⋯。ID、作者(都是同一个人)、
|
||||
* 精确到秒的两列时间拿掉
|
||||
*/
|
||||
const theme = useThemeVars()
|
||||
|
||||
const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
|
||||
{ title: "ID", key: "id", width: 60 },
|
||||
{ title: "标题", key: "title", minWidth: 300 },
|
||||
{ title: "标签", key: "tag", width: 80 },
|
||||
{
|
||||
title: "置顶",
|
||||
key: "top",
|
||||
render: (row) => (row.top ? "置顶" : ""),
|
||||
width: 80,
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 300,
|
||||
render: (row) =>
|
||||
h("span", { class: "title-cell" }, [
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
class: "title-link",
|
||||
to: { name: "admin announcement edit", params: { announcementID: row.id } },
|
||||
},
|
||||
() => row.title,
|
||||
),
|
||||
row.top ? h("span", { class: "top" }, "置顶") : null,
|
||||
h("span", { class: "muted small" }, row.tag),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
title: "发布",
|
||||
key: "createTime",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
width: 120,
|
||||
render: (row) => h("span", { class: "num" }, parseTime(row.createTime, "YYYY-MM-DD")),
|
||||
},
|
||||
{
|
||||
title: "上次更新时间",
|
||||
title: "改过",
|
||||
key: "lastUpdateTime",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.lastUpdateTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
const created = parseTime(row.createTime, "YYYY-MM-DD")
|
||||
const updated = parseTime(row.lastUpdateTime, "YYYY-MM-DD")
|
||||
return h("span", { class: "num muted" }, updated === created ? "—" : updated)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "作者",
|
||||
key: "createdBy",
|
||||
render: (row) => row.createdBy.username,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
title: "学生看得到",
|
||||
key: "visible",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row),
|
||||
onUpdateValue: () => toggle(row, "visible"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
render: (row) => h(Actions, { announcementID: row.id, onDeleted: listAnnouncements }),
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
announcementID: row.id,
|
||||
top: row.top,
|
||||
onDeleted: listAnnouncements,
|
||||
onToggleTop: () => toggle(row, "top"),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
// 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
|
||||
// 拿列表里那行直接回传会 400。所以先取回整条再改。
|
||||
async function toggleVisible(announcement: AdminAnnouncementListItem) {
|
||||
const next = !announcement.visible
|
||||
async function toggle(announcement: AdminAnnouncementListItem, field: "visible" | "top") {
|
||||
try {
|
||||
const full = await getAnnouncement(announcement.id)
|
||||
await editAnnouncement({
|
||||
@@ -73,12 +94,13 @@ async function toggleVisible(announcement: AdminAnnouncementListItem) {
|
||||
title: full.title,
|
||||
tag: full.tag,
|
||||
content: full.content,
|
||||
visible: next,
|
||||
top: full.top,
|
||||
visible: field === "visible" ? !full.visible : full.visible,
|
||||
top: field === "top" ? !full.top : full.top,
|
||||
})
|
||||
announcement.visible = next
|
||||
if (field === "top") listAnnouncements()
|
||||
else announcement.visible = !full.visible
|
||||
} catch {
|
||||
message.error("修改可见性失败")
|
||||
message.error("没改成,刷新一下再试")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +116,52 @@ watch(query, listAnnouncements, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="网站公告">
|
||||
<PageHeader title="公告">
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin announcement create' })">
|
||||
新建公告
|
||||
写一条公告
|
||||
</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table striped :columns="columns" :data="announcements" />
|
||||
<n-data-table class="ann-table" :columns="columns" :data="announcements" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ann-table :deep(.title-cell) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ann-table :deep(.title-link) {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ann-table :deep(.title-link:hover) {
|
||||
color: v-bind("theme.primaryColor");
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ann-table :deep(.top) {
|
||||
font-size: 12px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
color: v-bind("theme.warningColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
}
|
||||
|
||||
.ann-table :deep(.muted) {
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.ann-table :deep(.small) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ann-table :deep(.num) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user