feat: 添加新闻管理功能,包括新闻列表和添加新闻表单
This commit is contained in:
@@ -36,27 +36,16 @@ const fileList = ref<UploadFileInfo[]>([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function initFileList() {
|
async function initFileList() {
|
||||||
const fileIds = props.modelValue.filter(id => id && id.trim() !== '');
|
const fileIds = props.modelValue.filter(url => url && url.trim() !== '');
|
||||||
if (!props.modelValue || fileIds.length === 0) {
|
if (!props.modelValue || fileIds.length === 0) {
|
||||||
fileList.value = [];
|
fileList.value = [];
|
||||||
return;
|
} else {
|
||||||
}
|
fileList.value = props.modelValue.map(url => ({
|
||||||
loading.value = true;
|
id: url,
|
||||||
try {
|
name: url.split('/').pop() || 'file',
|
||||||
const { data } = await safeClient(client.api.file_storage.access_urls.post({ fileIds }));
|
url,
|
||||||
|
status: 'finished'
|
||||||
fileList.value =
|
}));
|
||||||
data.value?.map(item => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.fileName || item.id,
|
|
||||||
status: 'finished' as const,
|
|
||||||
url: item.url,
|
|
||||||
file: null as any
|
|
||||||
})) || [];
|
|
||||||
} catch (error) {
|
|
||||||
window.$message?.error('加载文件列表失败');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,19 +85,19 @@ async function handleCustomRequest({ file, onProgress, onFinish, onError }: Uplo
|
|||||||
...props.fetchOptions
|
...props.fetchOptions
|
||||||
} as UploadFetchOptions;
|
} as UploadFetchOptions;
|
||||||
|
|
||||||
const fileId = await uploadToS3(file.file as File, {
|
const res = await uploadToS3(file.file as File, {
|
||||||
fetchOptions: options,
|
fetchOptions: options,
|
||||||
onProgress: percent => {
|
onProgress: percent => {
|
||||||
onProgress({ percent });
|
onProgress({ percent });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 直接修改 file 对象的 id,NaiveUI 会自动同步到 fileList
|
// 直接修改 file 对象的 id,NaiveUI 会自动同步到 fileList
|
||||||
file.id = fileId;
|
file.id = res.fileId;
|
||||||
file.status = 'finished';
|
file.status = 'finished';
|
||||||
file.url = fileId;
|
file.url = res.publicUrl;
|
||||||
|
|
||||||
const newFileIds = [...props.modelValue, fileId].filter(Boolean);
|
const values = [...props.modelValue, res.publicUrl].filter(Boolean);
|
||||||
emit('update:modelValue', newFileIds);
|
emit('update:modelValue', values);
|
||||||
|
|
||||||
onFinish();
|
onFinish();
|
||||||
window.$message?.success(`文件 ${file.name} 上传成功`);
|
window.$message?.success(`文件 ${file.name} 上传成功`);
|
||||||
@@ -119,8 +108,8 @@ async function handleCustomRequest({ file, onProgress, onFinish, onError }: Uplo
|
|||||||
|
|
||||||
function handleRemove({ file }: { file: UploadFileInfo }) {
|
function handleRemove({ file }: { file: UploadFileInfo }) {
|
||||||
// 只删除上传成功的文件(有有效 id 的文件)
|
// 只删除上传成功的文件(有有效 id 的文件)
|
||||||
if (file.id && typeof file.id === 'string' && props.modelValue.includes(file.id)) {
|
if (file.url && typeof file.url === 'string' && props.modelValue.includes(file.url)) {
|
||||||
const newFileIds = props.modelValue.filter(id => id !== file.id);
|
const newFileIds = props.modelValue.filter(url => url !== file.url);
|
||||||
emit('update:modelValue', newFileIds);
|
emit('update:modelValue', newFileIds);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -230,7 +230,8 @@ const local: App.I18n.Schema = {
|
|||||||
'iframe-page': 'Iframe',
|
'iframe-page': 'Iframe',
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
product: 'Product',
|
product: 'Product',
|
||||||
user: ' User'
|
user: ' User',
|
||||||
|
news: 'News'
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ const local: App.I18n.Schema = {
|
|||||||
'iframe-page': '外链页面',
|
'iframe-page': '外链页面',
|
||||||
home: '首页',
|
home: '首页',
|
||||||
product: '产品管理',
|
product: '产品管理',
|
||||||
user: '用户管理'
|
user: '用户管理',
|
||||||
|
news: '新闻管理'
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||||
login: () => import("@/views/_builtin/login/index.vue"),
|
login: () => import("@/views/_builtin/login/index.vue"),
|
||||||
home: () => import("@/views/home/index.vue"),
|
home: () => import("@/views/home/index.vue"),
|
||||||
|
news: () => import("@/views/news/index.vue"),
|
||||||
product: () => import("@/views/product/index.vue"),
|
product: () => import("@/views/product/index.vue"),
|
||||||
user: () => import("@/views/user/index.vue"),
|
user: () => import("@/views/user/index.vue"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
hideInMenu: true
|
hideInMenu: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'news',
|
||||||
|
path: '/news',
|
||||||
|
component: 'layout.base$view.news',
|
||||||
|
meta: {
|
||||||
|
title: 'news',
|
||||||
|
i18nKey: 'route.news'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'product',
|
name: 'product',
|
||||||
path: '/product',
|
path: '/product',
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ const routeMap: RouteMap = {
|
|||||||
"home": "/home",
|
"home": "/home",
|
||||||
"iframe-page": "/iframe-page/:url",
|
"iframe-page": "/iframe-page/:url",
|
||||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
|
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
|
||||||
|
"news": "/news",
|
||||||
"product": "/product",
|
"product": "/product",
|
||||||
"user": "/user"
|
"user": "/user"
|
||||||
};
|
};
|
||||||
|
|||||||
3
src/typings/elegant-router.d.ts
vendored
3
src/typings/elegant-router.d.ts
vendored
@@ -23,6 +23,7 @@ declare module "@elegant-router/types" {
|
|||||||
"home": "/home";
|
"home": "/home";
|
||||||
"iframe-page": "/iframe-page/:url";
|
"iframe-page": "/iframe-page/:url";
|
||||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
|
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
|
||||||
|
"news": "/news";
|
||||||
"product": "/product";
|
"product": "/product";
|
||||||
"user": "/user";
|
"user": "/user";
|
||||||
};
|
};
|
||||||
@@ -62,6 +63,7 @@ declare module "@elegant-router/types" {
|
|||||||
| "home"
|
| "home"
|
||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
| "login"
|
| "login"
|
||||||
|
| "news"
|
||||||
| "product"
|
| "product"
|
||||||
| "user"
|
| "user"
|
||||||
>;
|
>;
|
||||||
@@ -86,6 +88,7 @@ declare module "@elegant-router/types" {
|
|||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
| "login"
|
| "login"
|
||||||
| "home"
|
| "home"
|
||||||
|
| "news"
|
||||||
| "product"
|
| "product"
|
||||||
| "user"
|
| "user"
|
||||||
>;
|
>;
|
||||||
|
|||||||
4
src/typings/treaty.d.ts
vendored
Normal file
4
src/typings/treaty.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare namespace Treaty {
|
||||||
|
type Query<T> = T extends (...args: any[]) => any ? NonNullable<NonNullable<Parameters<T>[0]>['query']> : never;
|
||||||
|
type Body<T> = T extends (...args: any[]) => any ? NonNullable<Parameters<T>[0]> : never;
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export async function uploadToS3(file: File, options: UploadOptions) {
|
|||||||
const { fileId, uploadUrl, method, headers, publicUrl } = toRefs(data.value);
|
const { fileId, uploadUrl, method, headers, publicUrl } = toRefs(data.value);
|
||||||
|
|
||||||
// 2. 上传文件到 S3
|
// 2. 上传文件到 S3
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<{ fileId: string; publicUrl: string }>((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
// 监听上传进度
|
// 监听上传进度
|
||||||
@@ -40,7 +40,10 @@ export async function uploadToS3(file: File, options: UploadOptions) {
|
|||||||
// 上传成功
|
// 上传成功
|
||||||
xhr.addEventListener('load', () => {
|
xhr.addEventListener('load', () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
resolve(fileId.value);
|
resolve({
|
||||||
|
fileId: fileId.value,
|
||||||
|
publicUrl: publicUrl!.value!
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`上传失败: ${xhr.status} ${xhr.statusText}`));
|
reject(new Error(`上传失败: ${xhr.status} ${xhr.statusText}`));
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/views/news/components/add.vue
Normal file
142
src/views/news/components/add.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { FormInst, FormRules, UploadFileInfo } from 'naive-ui';
|
||||||
|
import { client, safeClient } from '@/service/api';
|
||||||
|
import UploadS3 from '@/components/upload/index.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'NewsAdd'
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type NewsItem = Treaty.Body<typeof client.api.admin.news.post>;
|
||||||
|
|
||||||
|
const formRef = ref<FormInst | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const formModel = ref<NewsItem>({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
content: '',
|
||||||
|
hasVideo: false,
|
||||||
|
isHot: false,
|
||||||
|
isPinned: false,
|
||||||
|
thumbnailId: null,
|
||||||
|
attachmentIds: [],
|
||||||
|
sortOrder: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||||
|
summary: [{ required: true, message: '请输入摘要', trigger: 'blur' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await safeClient(() =>
|
||||||
|
client.api.admin.news.post({
|
||||||
|
...formModel.value
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
window.$message?.success('创建成功');
|
||||||
|
resetForm();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
formModel.value = {
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
content: '',
|
||||||
|
hasVideo: false,
|
||||||
|
isHot: false,
|
||||||
|
isPinned: false,
|
||||||
|
attachmentIds: [],
|
||||||
|
sortOrder: 0,
|
||||||
|
publishedAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NForm ref="formRef" :model="formModel" :rules="rules" label-placement="left" :label-width="100">
|
||||||
|
<NFormItem label="缩略图" path="thumbnailId">
|
||||||
|
<UploadS3
|
||||||
|
:model-value="formModel.thumbnailId ? [formModel.thumbnailId] : undefined"
|
||||||
|
:max-size="20"
|
||||||
|
:max-files="1"
|
||||||
|
accept="image/*"
|
||||||
|
placeholder="上传图片"
|
||||||
|
:fetch-options="{ businessType: 'news_attachment' }"
|
||||||
|
@update:model-value="evt => (formModel.thumbnailId = evt.length > 0 ? evt[0] : undefined)"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="标题" path="title">
|
||||||
|
<NInput v-model:value="formModel.title" placeholder="请输入标题" />
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="摘要" path="summary">
|
||||||
|
<NInput
|
||||||
|
v-model:value="formModel.summary"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入摘要"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="内容" path="content">
|
||||||
|
<NInput
|
||||||
|
v-model:value="formModel.content"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入内容"
|
||||||
|
:autosize="{ minRows: 5, maxRows: 10 }"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NGrid cols="2" x-gap="16px">
|
||||||
|
<NFormItemGi label="是否热门" path="isHot">
|
||||||
|
<NSwitch v-model:value="formModel.isHot" />
|
||||||
|
</NFormItemGi>
|
||||||
|
|
||||||
|
<NFormItemGi label="是否置顶" path="isPinned">
|
||||||
|
<NSwitch v-model:value="formModel.isPinned" />
|
||||||
|
</NFormItemGi>
|
||||||
|
</NGrid>
|
||||||
|
|
||||||
|
<NFormItem label="排序值" path="sortOrder">
|
||||||
|
<NInputNumber v-model:value="formModel.sortOrder" placeholder="请输入排序值" class="w-full" />
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="附件文件" path="attachmentIds">
|
||||||
|
<UploadS3
|
||||||
|
:model-value="formModel.attachmentIds || []"
|
||||||
|
:max-size="20"
|
||||||
|
:max-files="3"
|
||||||
|
placeholder="上传附件"
|
||||||
|
:fetch-options="{ businessType: 'news_attachment' }"
|
||||||
|
@update:model-value="evt => (formModel.attachmentIds = evt.length > 0 ? evt : undefined)"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<div class="mt-16px flex justify-end gap-12px">
|
||||||
|
<NButton @click="emit('close')">取消</NButton>
|
||||||
|
<NButton type="primary" :loading="loading" @click="handleSubmit">确定</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
95
src/views/news/index.vue
Normal file
95
src/views/news/index.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { h, useTemplateRef } from 'vue';
|
||||||
|
import { useDialog } from 'naive-ui';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { client, safeClient } from '@/service/api';
|
||||||
|
import type { TableBaseColumns, TableFetchData, TableInst } from '@/components/table';
|
||||||
|
import Add from './components/add.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'NewsManagement'
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialog = useDialog();
|
||||||
|
const tableInst = useTemplateRef<TableInst>('tableInst');
|
||||||
|
|
||||||
|
const fetchData: TableFetchData = ({ pagination, filter }) => {
|
||||||
|
return safeClient(() => client.api.admin.news.get({ query: { ...pagination, ...filter } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableBaseColumns = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
title: '标题'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'summary',
|
||||||
|
title: '摘要'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hasVideo',
|
||||||
|
title: '是否有视频'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isHot',
|
||||||
|
title: '是否热门'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isPinned',
|
||||||
|
title: '是否置顶'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'attachmentIds',
|
||||||
|
title: '附件文件个数',
|
||||||
|
render: (row: any) => {
|
||||||
|
return row.attachmentIds ? row.attachmentIds.length : 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sortOrder',
|
||||||
|
title: '排序值'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'publishedAt',
|
||||||
|
title: '发布时间',
|
||||||
|
render: (row: any) => {
|
||||||
|
return dayjs(row.publishedAt).format('YYYY-MM-DD HH:mm');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'operations',
|
||||||
|
title: '操作',
|
||||||
|
width: 150,
|
||||||
|
fixed: 'right',
|
||||||
|
operations: (row: any) => [
|
||||||
|
{
|
||||||
|
contentText: '编辑',
|
||||||
|
size: 'small',
|
||||||
|
onClick: () => {
|
||||||
|
// 编辑操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
dialog.create({
|
||||||
|
title: '添加新闻',
|
||||||
|
showIcon: false,
|
||||||
|
style: { width: '800px' },
|
||||||
|
content: () =>
|
||||||
|
h(Add, {
|
||||||
|
onClose: () => {
|
||||||
|
tableInst.value?.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableBase ref="tableInst" :fetch-data="fetchData" :columns="columns" @add="handleAdd" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css" scoped></style>
|
||||||
Reference in New Issue
Block a user