Feature/event platform 1.09 cst #21
@@ -52,6 +52,11 @@ system.roles.add # 角色权限-新增(筛选栏中的"新增
|
||||
system.roles.edit # 角色权限-编辑(表格操作列中的"编辑"按钮)
|
||||
system.roles.delete # 角色权限-删除(表格操作列中的"删除"按钮,红色危险样式)
|
||||
|
||||
system.notices.view # 公告管理-查看(页面默认查看权限)
|
||||
system.notices.add # 公告管理-新增(筛选栏中的"新增"按钮)
|
||||
system.notices.edit # 公告管理-编辑(表格操作列中的"修改"按钮)
|
||||
system.notices.delete # 公告管理-删除(表格操作列中的"删除"按钮,红色危险样式)
|
||||
|
||||
system.logs.view # 操作日志-查看(页面默认查看权限,无操作列按钮)
|
||||
|
||||
system.params.view # 参数配置-查看
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 公告管理 API
|
||||
*/
|
||||
import { get, post } from '@/utils/request';
|
||||
import type {
|
||||
NoticeListQueryParams,
|
||||
NoticeSaveParams,
|
||||
NoticeVO,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
NoticeType,
|
||||
NoticeVO,
|
||||
NoticeListQueryParams,
|
||||
NoticeSaveParams,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from './types';
|
||||
|
||||
// ============================================================
|
||||
// URL 常量
|
||||
// ============================================================
|
||||
const ANNOUNCEMENT_PAGE = '/admin/sys/announcement/page';
|
||||
const ANNOUNCEMENT_SAVE = '/admin/sys/announcement/save';
|
||||
const ANNOUNCEMENT_UPDATE = '/admin/sys/announcement/update';
|
||||
const ANNOUNCEMENT_DELETE = '/admin/sys/announcement/delete';
|
||||
|
||||
// ============================================================
|
||||
// API 函数
|
||||
// ============================================================
|
||||
|
||||
/** 公告列表分页 — GET /admin/sys/announcement/page */
|
||||
export function getNoticeList(
|
||||
params: NoticeListQueryParams,
|
||||
): Promise<ApiResult<PageData<NoticeVO>>> {
|
||||
return get(ANNOUNCEMENT_PAGE, params as Record<string, any>);
|
||||
}
|
||||
|
||||
/** 新增公告 — POST /admin/sys/announcement/save */
|
||||
export function saveNotice(params: NoticeSaveParams): Promise<ApiResult<null>> {
|
||||
return post(ANNOUNCEMENT_SAVE, params);
|
||||
}
|
||||
|
||||
/** 修改公告 — POST /admin/sys/announcement/update */
|
||||
export function updateNotice(params: NoticeSaveParams): Promise<ApiResult<null>> {
|
||||
return post(ANNOUNCEMENT_UPDATE, params);
|
||||
}
|
||||
|
||||
/** 删除公告(物理删除,单个)— POST /admin/sys/announcement/delete?id=xxx */
|
||||
export function deleteNotice(id: number): Promise<ApiResult<null>> {
|
||||
return post(ANNOUNCEMENT_DELETE, undefined, { id: String(id) });
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 公告管理 - 类型定义
|
||||
*/
|
||||
|
||||
/** 公告类型:1=首页公告提示,2=产品更新公告 */
|
||||
export type NoticeType = 1 | 2;
|
||||
|
||||
/** 公告 VO */
|
||||
export interface NoticeVO {
|
||||
/** 公告 ID */
|
||||
id: number;
|
||||
/** 标题(最多 30 字) */
|
||||
title: string;
|
||||
/** 公告类型:1=首页公告提示,2=产品更新公告 */
|
||||
type: number;
|
||||
/** 公告类型名称 */
|
||||
typeName: string;
|
||||
/** 生效开始时间(YYYY-MM-DD HH:mm:ss) */
|
||||
startTime: string;
|
||||
/** 生效结束时间(YYYY-MM-DD HH:mm:ss) */
|
||||
endTime: string;
|
||||
/** 公告内容(纯文本,最多 500 字) */
|
||||
context: string;
|
||||
/** 创建时间 */
|
||||
createDate: string;
|
||||
}
|
||||
|
||||
/** 公告列表分页查询参数 */
|
||||
export interface NoticeListQueryParams {
|
||||
page: string;
|
||||
limit: string;
|
||||
/** 标题,模糊搜索 */
|
||||
title?: string;
|
||||
/** 公告类型 */
|
||||
type?: number;
|
||||
}
|
||||
|
||||
/** 新增/修改公告参数(id:新增不传或为 0,修改必传) */
|
||||
export interface NoticeSaveParams {
|
||||
id?: number;
|
||||
title: string;
|
||||
type: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
/** 分页数据结构 */
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
exData?: Record<string, any>;
|
||||
}
|
||||
|
||||
/** 接口通用返回结构 */
|
||||
export interface ApiResult<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
@@ -164,6 +164,17 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
||||
icon: 'AiOutlineSafetyCertificate',
|
||||
permission: 'system.roles.view',
|
||||
},
|
||||
{
|
||||
// 公告管理:后端 /op/permission 尚未下发 system.notices.view 权限码,
|
||||
// 暂不挂 permission(否则整条菜单会被权限过滤掉);后端配置后可在下方补回
|
||||
// permission: 'system.notices.view',
|
||||
id: 'system_notices',
|
||||
name: '公告管理',
|
||||
path: 'system/notices',
|
||||
component: 'system/notices',
|
||||
componentName: 'SystemNotices',
|
||||
icon: 'AiOutlineSound',
|
||||
},
|
||||
{
|
||||
id: 'system_params',
|
||||
name: '参数配置',
|
||||
|
||||
@@ -223,11 +223,14 @@ export default defineComponent({
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={filterForm.status}
|
||||
options={EVENT_STATUS_OPTIONS as any}
|
||||
style={{ width: '150px' }}
|
||||
style={{ width: '240px' }}
|
||||
placeholder="全部"
|
||||
maxTagCount={2}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.status = val || '')}
|
||||
onUpdate:value={(val: any) => (filterForm.status = val || [])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="公开活动" name="isPublic">
|
||||
|
||||
@@ -10,7 +10,8 @@ export const FILTER_DEFAULTS = {
|
||||
startDateRange: null as [string, string] | null,
|
||||
name: '',
|
||||
createName: '',
|
||||
status: '',
|
||||
// 状态多选:默认选中「报名中(0)」「进行中(1)」
|
||||
status: ['0', '1'] as string[],
|
||||
region: [] as string[],
|
||||
isPublic: '',
|
||||
idCardNo: '',
|
||||
@@ -28,7 +29,8 @@ export type FieldMapping = [
|
||||
export const FIELD_MAPPINGS: FieldMapping[] = [
|
||||
['name', 'name', (v) => v?.trim?.() ?? v],
|
||||
['createName', 'createName', (v) => v?.trim?.() ?? v],
|
||||
['status', 'status'],
|
||||
// 多选数组拼成逗号串传给后端,如 "0,1"
|
||||
['status', 'status', (v) => (Array.isArray(v) ? v.join(',') : v)],
|
||||
['isPublic', 'isPublic'],
|
||||
['idCardNo', 'idCardNo'],
|
||||
['idCardImg', 'idCardImg'],
|
||||
@@ -41,9 +43,8 @@ export const FIELD_MAPPINGS: FieldMapping[] = [
|
||||
['scEnd', 'scEnd'],
|
||||
];
|
||||
|
||||
/** 赛事状态选项(value 对应 API status) */
|
||||
/** 赛事状态选项(多选,value 对应 API status;不选即全部) */
|
||||
export const EVENT_STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '0', label: '报名中' },
|
||||
{ value: '1', label: '进行中' },
|
||||
{ value: '2', label: '已结束' },
|
||||
|
||||
@@ -88,7 +88,7 @@ export function useEventListModel() {
|
||||
!!f.startDateRange ||
|
||||
!!debouncedName.value?.trim() ||
|
||||
!!debouncedCreator.value?.trim() ||
|
||||
!!f.status ||
|
||||
(Array.isArray(f.status) ? f.status.length > 0 : !!f.status) ||
|
||||
f.region.length > 0 ||
|
||||
!!f.isPublic ||
|
||||
!!f.idCardNo ||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.noticeFormModalMain {
|
||||
.form {
|
||||
:global {
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { defineComponent, ref, reactive, nextTick } from 'vue';
|
||||
import { useEffect } from '@/hooks';
|
||||
import { Modal, Form, Input, Select, DatePicker, Button, message } from 'ant-design-vue';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { NOTICE_TYPE_OPTIONS } from '../model/useNoticeModel';
|
||||
import { getNoticeList, type NoticeVO } from '../model/services';
|
||||
import {
|
||||
TITLE_MAX,
|
||||
CONTENT_MAX,
|
||||
SUBMIT_TIME_FORMAT,
|
||||
typeRules,
|
||||
timeRangeRules,
|
||||
titleRules,
|
||||
contextRules,
|
||||
isTimeRangeConflict,
|
||||
} from '../model/form';
|
||||
import styles from './NoticeFormModal.module.less';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface NoticeFormModalProps {
|
||||
visible: boolean;
|
||||
record: NoticeVO | null;
|
||||
submitting?: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (formData: any) => void;
|
||||
}
|
||||
|
||||
/** 默认表单数据 */
|
||||
const getDefaultForm = () => ({
|
||||
type: 1 as number,
|
||||
timeRange: null as [Dayjs, Dayjs] | null,
|
||||
title: '',
|
||||
context: '',
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NoticeFormModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
record: { type: Object as () => NoticeVO | null, default: null },
|
||||
submitting: { type: Boolean, default: false },
|
||||
onClose: { type: Function, required: true },
|
||||
onSave: { type: Function, required: true },
|
||||
},
|
||||
setup(props: NoticeFormModalProps) {
|
||||
const formRef = ref<any>();
|
||||
const formData = reactive(getDefaultForm());
|
||||
/** 生效时间交叉校验中 */
|
||||
const timeRangeChecking = ref(false);
|
||||
|
||||
/** 根据 record 初始化表单 */
|
||||
const initFormFromRecord = (record: NoticeVO | null) => {
|
||||
if (!record) {
|
||||
Object.assign(formData, getDefaultForm());
|
||||
return;
|
||||
}
|
||||
const startTime = record.startTime ? dayjs(record.startTime) : null;
|
||||
const endTime = record.endTime ? dayjs(record.endTime) : null;
|
||||
Object.assign(formData, {
|
||||
type: record.type || 1,
|
||||
timeRange: startTime?.isValid() && endTime?.isValid() ? [startTime, endTime] : null,
|
||||
title: record.title || '',
|
||||
context: record.context || '',
|
||||
});
|
||||
};
|
||||
|
||||
/** 监听 visible 变化重置表单 */
|
||||
useEffect(() => {
|
||||
if (props.visible) {
|
||||
initFormFromRecord(props.record);
|
||||
nextTick().then(() => formRef.value?.clearValidate());
|
||||
}
|
||||
}, [() => props.visible]);
|
||||
|
||||
/**
|
||||
* 生效时间交叉校验:同一类型下,时间区间不允许与已存在公告交叉
|
||||
* 校验通过返回 true
|
||||
*/
|
||||
const validateTimeRange = async (startTime: Dayjs, endTime: Dayjs): Promise<boolean> => {
|
||||
timeRangeChecking.value = true;
|
||||
try {
|
||||
const res = await getNoticeList({ page: '1', limit: '999', type: formData.type });
|
||||
if (res.code != 200) return true;
|
||||
|
||||
const editingId = props.record ? String(props.record.id) : '';
|
||||
const conflictNotice = (res.data?.list || []).find(
|
||||
(item) =>
|
||||
String(item.id) !== editingId &&
|
||||
isTimeRangeConflict(startTime, endTime, item.startTime, item.endTime),
|
||||
);
|
||||
|
||||
if (conflictNotice) {
|
||||
message.error(`生效时间与公告「${conflictNotice.title}」存在交叉,请重新选择`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// 查询失败不阻塞保存,交由后端二次校验
|
||||
return true;
|
||||
} finally {
|
||||
timeRangeChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 提交 */
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const [startTime, endTime] = formData.timeRange || [];
|
||||
if (!startTime || !endTime) return;
|
||||
|
||||
const isTimeRangeValid = await validateTimeRange(startTime, endTime);
|
||||
if (!isTimeRangeValid) return;
|
||||
|
||||
props.onSave({
|
||||
type: formData.type,
|
||||
title: formData.title.trim(),
|
||||
context: formData.context.trim(),
|
||||
startTime: startTime.format(SUBMIT_TIME_FORMAT),
|
||||
endTime: endTime.format(SUBMIT_TIME_FORMAT),
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
const isEdit = !!props.record;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? '编辑公告' : '新增公告'}
|
||||
visible={props.visible}
|
||||
onCancel={props.onClose}
|
||||
width={560}
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
centered
|
||||
wrapClassName={styles.noticeFormModalMain}
|
||||
class={styles.modal}
|
||||
>
|
||||
<Form ref={formRef} layout="vertical" model={formData} class={styles.form} requiredMark>
|
||||
{/* 类型 */}
|
||||
<Form.Item label="类型" name="type" rules={typeRules}>
|
||||
<Select
|
||||
value={formData.type}
|
||||
options={NOTICE_TYPE_OPTIONS as any}
|
||||
placeholder="请选择"
|
||||
onUpdate:value={(val: any) => (formData.type = val as number)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 生效时间 */}
|
||||
<Form.Item label="生效时间" name="timeRange" rules={timeRangeRules}>
|
||||
<RangePicker
|
||||
showTime
|
||||
format={SUBMIT_TIME_FORMAT}
|
||||
value={formData.timeRange as any}
|
||||
placeholder={['开始时间', '结束时间']}
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.timeRange = (val as [Dayjs, Dayjs]) || null;
|
||||
formRef.value?.clearValidate('timeRange');
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 标题 */}
|
||||
<Form.Item label="标题" name="title" rules={titleRules}>
|
||||
<Input
|
||||
value={formData.title}
|
||||
placeholder="请输入公告标题"
|
||||
maxlength={TITLE_MAX}
|
||||
showCount
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.title = val?.target?.value ?? val ?? '';
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 内容 */}
|
||||
<Form.Item label="内容" name="context" rules={contextRules}>
|
||||
<TextArea
|
||||
value={formData.context}
|
||||
placeholder="请输入公告内容"
|
||||
maxlength={CONTENT_MAX}
|
||||
showCount
|
||||
rows={6}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.context = val?.target?.value ?? val ?? '';
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div class={styles.footer}>
|
||||
<Button onClick={props.onClose}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={props.submitting || timeRangeChecking.value}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { Button, Input, Table, Form, Space, Modal, Pagination } from 'ant-design-vue';
|
||||
import type { ModalProps } from 'ant-design-vue';
|
||||
import { useNoticeModel } from './model/useNoticeModel';
|
||||
import { formatEffectTime } from './model/form';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import NoticeFormModal from './components/NoticeFormModal';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
|
||||
/**
|
||||
* bodyCell 渲染
|
||||
*/
|
||||
function renderBodyCell({
|
||||
column,
|
||||
text,
|
||||
record,
|
||||
onEdit,
|
||||
onDelete,
|
||||
permissions,
|
||||
}: {
|
||||
column: any;
|
||||
text: any;
|
||||
record: any;
|
||||
onEdit: (record: any) => void;
|
||||
onDelete: (record: any) => void;
|
||||
permissions: {
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
}) {
|
||||
const { canEdit, canDelete } = permissions;
|
||||
|
||||
return column.key === 'effectTime' ? (
|
||||
// 生效时间:开始时间 至 结束时间
|
||||
<span>{formatEffectTime(record.startTime, record.endTime)}</span>
|
||||
) : column.key === 'action' ? (
|
||||
!canEdit && !canDelete ? (
|
||||
<span>--</span>
|
||||
) : (
|
||||
<Space>
|
||||
{canEdit && (
|
||||
<Button type="link" size="small" onClick={() => onEdit(record)}>
|
||||
修改
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button type="link" size="small" danger onClick={() => onDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
) : (
|
||||
<span>{text ?? '--'}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除公告二次确认
|
||||
*/
|
||||
function confirmDelete({ record, onOk }: { record: any; onOk: (record: any) => void }) {
|
||||
Modal.confirm({
|
||||
title: '删除确认',
|
||||
class: 'deleteConfirmModal',
|
||||
content: `确认要删除“${record.title}”吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true } as ModalProps['okButtonProps'],
|
||||
onOk: () => onOk(record),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统管理 - 公告管理
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'SystemNotices',
|
||||
setup() {
|
||||
const {
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
columns,
|
||||
pagination,
|
||||
formVisible,
|
||||
editingRecord,
|
||||
formSubmitting,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
handleAdd,
|
||||
handleEdit,
|
||||
handleCloseForm,
|
||||
handleSave,
|
||||
handleDelete,
|
||||
} = useNoticeModel();
|
||||
|
||||
const { containerRef, height } = useContainerSize();
|
||||
const { hasPermission, hasAnyPermission } = usePermissionStore();
|
||||
|
||||
// 公告模块的权限码后端尚未配置时(/op/permission 未下发),按钮默认放行,
|
||||
// 待后端下发任意一个 system.notices.* 权限码后自动切换为按码控制
|
||||
const noticePermsConfigured = hasAnyPermission([
|
||||
'system.notices.view',
|
||||
'system.notices.add',
|
||||
'system.notices.edit',
|
||||
'system.notices.delete',
|
||||
]);
|
||||
const canAdd = !noticePermsConfigured || hasPermission('system.notices.add');
|
||||
const operationPermissions = {
|
||||
canEdit: !noticePermsConfigured || hasPermission('system.notices.edit'),
|
||||
canDelete: !noticePermsConfigured || hasPermission('system.notices.delete'),
|
||||
};
|
||||
|
||||
const triggerDelete = (record: any) => confirmDelete({ record, onOk: handleDelete });
|
||||
|
||||
const tableColumns = [
|
||||
...columns,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return () => (
|
||||
<div class={pageStyles.containerMain}>
|
||||
{/* ===== 筛选区 ===== */}
|
||||
<div class={pageStyles.filter}>
|
||||
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
|
||||
<Form.Item label="标题" name="title">
|
||||
<Input
|
||||
value={filterForm.title}
|
||||
placeholder="请输入"
|
||||
style={{ width: '200px' }}
|
||||
allowClear
|
||||
onUpdate:value={(val: any) => (filterForm.title = val?.target?.value ?? val ?? '')}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||
查询
|
||||
</Button>
|
||||
{canAdd && (
|
||||
<Button type="primary" onClick={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* ===== 表格区 ===== */}
|
||||
<div class={pageStyles.table}>
|
||||
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={dataSource.value}
|
||||
loading={loading.value}
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
>
|
||||
{{
|
||||
bodyCell: (args: any) =>
|
||||
renderBodyCell({
|
||||
...args,
|
||||
onEdit: handleEdit,
|
||||
onDelete: triggerDelete,
|
||||
permissions: operationPermissions,
|
||||
}),
|
||||
}}
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div class={pageStyles.pagination}>
|
||||
<Pagination
|
||||
current={(pagination as any).current.value}
|
||||
pageSize={(pagination as any).pageSize.value}
|
||||
total={(pagination as any).total.value}
|
||||
showSizeChanger
|
||||
showTotal={(total: number) => `共 ${total} 条`}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 新增/编辑公告弹窗 ===== */}
|
||||
{formVisible.value && (
|
||||
<NoticeFormModal
|
||||
visible={formVisible.value}
|
||||
record={editingRecord.value}
|
||||
submitting={formSubmitting.value}
|
||||
onClose={handleCloseForm}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 公告管理 - 表单校验规则 & 时间处理工具
|
||||
*/
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
|
||||
/** 标题最多字数 */
|
||||
export const TITLE_MAX = 30;
|
||||
/** 内容最多字数 */
|
||||
export const CONTENT_MAX = 500;
|
||||
|
||||
/** 时间展示格式(精确到分钟) */
|
||||
export const DISPLAY_TIME_FORMAT = 'YYYY-MM-DD HH:mm';
|
||||
/** 提交接口的时间格式 */
|
||||
export const SUBMIT_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
/** 公告类型校验 */
|
||||
export const typeRules = [
|
||||
{ required: true, message: '请选择公告类型', trigger: 'change' as const },
|
||||
];
|
||||
|
||||
/** 生效时间校验(开始 + 结束) */
|
||||
export const timeRangeRules = [
|
||||
{
|
||||
required: true,
|
||||
type: 'array' as const,
|
||||
min: 2,
|
||||
message: '请选择生效时间',
|
||||
trigger: 'change' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/** 标题校验 */
|
||||
export const titleRules = [
|
||||
{ required: true, whitespace: true, message: '请输入公告标题', trigger: 'blur' as const },
|
||||
{ max: TITLE_MAX, message: `最多可输入${TITLE_MAX}字`, trigger: 'blur' as const },
|
||||
];
|
||||
|
||||
/** 内容校验 */
|
||||
export const contextRules = [
|
||||
{ required: true, whitespace: true, message: '请输入公告内容', trigger: 'blur' as const },
|
||||
{ max: CONTENT_MAX, message: `最多可输入${CONTENT_MAX}字`, trigger: 'blur' as const },
|
||||
];
|
||||
|
||||
/** 将时间字符串格式化为展示格式(解析失败时原样返回) */
|
||||
export function formatDisplayTime(value?: string): string {
|
||||
if (!value) return '--';
|
||||
const parsedTime = dayjs(value);
|
||||
return parsedTime.isValid() ? parsedTime.format(DISPLAY_TIME_FORMAT) : value;
|
||||
}
|
||||
|
||||
/** 列表「生效时间」列:开始时间 至 结束时间 */
|
||||
export function formatEffectTime(startTime?: string, endTime?: string): string {
|
||||
if (!startTime && !endTime) return '--';
|
||||
return `${formatDisplayTime(startTime)} 至 ${formatDisplayTime(endTime)}`;
|
||||
}
|
||||
|
||||
/** 判断两个时间区间是否交叉(首尾相接不算交叉) */
|
||||
export function isTimeRangeConflict(
|
||||
start: Dayjs,
|
||||
end: Dayjs,
|
||||
otherStart?: string,
|
||||
otherEnd?: string,
|
||||
): boolean {
|
||||
const otherStartTime = dayjs(otherStart);
|
||||
const otherEndTime = dayjs(otherEnd);
|
||||
if (!otherStartTime.isValid() || !otherEndTime.isValid()) return false;
|
||||
return start.isBefore(otherEndTime) && otherStartTime.isBefore(end);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 公告管理 - 接口服务
|
||||
* 该页面使用的所有接口集中管理(统一收敛到 @/api/notices)
|
||||
*/
|
||||
export { getNoticeList, saveNotice, updateNotice, deleteNotice } from '@/api/notices';
|
||||
|
||||
export type {
|
||||
NoticeVO,
|
||||
NoticeListQueryParams,
|
||||
NoticeSaveParams,
|
||||
PageData,
|
||||
ApiResult,
|
||||
} from '@/api/notices';
|
||||
@@ -0,0 +1,196 @@
|
||||
import { computed, reactive, nextTick } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useState, useThrottleFn } from '@/hooks';
|
||||
import { useRequest } from '@/hooks/useRequest';
|
||||
import { useEffect } from '@/hooks/useEffect';
|
||||
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
|
||||
import {
|
||||
getNoticeList,
|
||||
saveNotice,
|
||||
updateNotice,
|
||||
deleteNotice,
|
||||
type NoticeVO,
|
||||
type NoticeListQueryParams,
|
||||
type PageData,
|
||||
type ApiResult,
|
||||
} from './services';
|
||||
|
||||
// ============================================================
|
||||
// 常量
|
||||
// ============================================================
|
||||
|
||||
/** 公告类型选项(筛选 / 表单共用) */
|
||||
export const NOTICE_TYPE_OPTIONS = [
|
||||
{ value: 1, label: '首页公告提示' },
|
||||
{ value: 2, label: '产品更新公告' },
|
||||
] as const;
|
||||
|
||||
/** 公告类型文案映射(兜底,后端 typeName 为空时使用) */
|
||||
export const NOTICE_TYPE_MAP: Record<number, string> = {
|
||||
1: '首页公告提示',
|
||||
2: '产品更新公告',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Model
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 公告管理页数据模型
|
||||
*/
|
||||
export function useNoticeModel() {
|
||||
// ===== 筛选条件 =====
|
||||
const filterForm = reactive({
|
||||
title: '', // 公告标题
|
||||
});
|
||||
|
||||
// ===== 分页 =====
|
||||
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
|
||||
|
||||
// ===== 构建 API 查询参数 =====
|
||||
const buildQueryParams = (): NoticeListQueryParams => {
|
||||
const params: NoticeListQueryParams = {
|
||||
page: String((pagination as any).current.value),
|
||||
limit: String((pagination as any).pageSize.value),
|
||||
};
|
||||
if (filterForm.title.trim()) params.title = filterForm.title.trim();
|
||||
return params;
|
||||
};
|
||||
|
||||
// ===== 数据请求 =====
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
run: fetchList,
|
||||
} = useRequest<ApiResult<PageData<NoticeVO>>>(() => getNoticeList(buildQueryParams()), {
|
||||
refreshDeps: [],
|
||||
formatResult: (res) => res,
|
||||
});
|
||||
|
||||
const dataSource = computed(() => {
|
||||
const res = data.value;
|
||||
return res ? (res as any).data?.list || [] : [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
syncPaginationTotal(pagination, (data.value as any)?.data?.total);
|
||||
}, [data]);
|
||||
|
||||
// ===== 弹窗状态 =====
|
||||
const [formVisible, setFormVisible] = useState<boolean>(false);
|
||||
const [editingRecord, setEditingRecord] = useState<NoticeVO | null>(null);
|
||||
|
||||
// ===== 提交态 =====
|
||||
const [formSubmitting, setFormSubmitting] = useState<boolean>(false);
|
||||
|
||||
// ===== 表格列配置 =====
|
||||
const columns = [
|
||||
{ title: '标题', dataIndex: 'title', key: 'title' },
|
||||
{ title: '公告类型', dataIndex: 'typeName', key: 'typeName', width: 200 },
|
||||
{ title: '生效时间', key: 'effectTime', width: 340 },
|
||||
{ title: '创建时间', dataIndex: 'createDate', key: 'createDate', width: 200 },
|
||||
];
|
||||
|
||||
// ===== 计算属性 =====
|
||||
const hasFilter = computed(() => filterForm.title.trim() !== '');
|
||||
const isEdit = computed(() => editingRecord.value !== null);
|
||||
|
||||
// ===== 方法 =====
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.reset();
|
||||
fetchList();
|
||||
};
|
||||
|
||||
/** 重置(重置后自动查询) */
|
||||
const handleReset = useThrottleFn(async () => {
|
||||
filterForm.title = '';
|
||||
pagination.reset();
|
||||
await fetchList();
|
||||
}, 500);
|
||||
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
pagination.setCurrent(page);
|
||||
if (pageSize !== (pagination as any).pageSize.value) {
|
||||
pagination.setPageSize(pageSize);
|
||||
}
|
||||
nextTick().then(() => fetchList());
|
||||
};
|
||||
|
||||
/** 新增 */
|
||||
const handleAdd = () => {
|
||||
setEditingRecord(null);
|
||||
setFormVisible(true);
|
||||
};
|
||||
|
||||
/** 编辑 */
|
||||
const handleEdit = (record: NoticeVO) => {
|
||||
setEditingRecord(record);
|
||||
setFormVisible(true);
|
||||
};
|
||||
|
||||
/** 关闭表单弹窗 */
|
||||
const handleCloseForm = () => {
|
||||
setFormVisible(false);
|
||||
setEditingRecord(null);
|
||||
};
|
||||
|
||||
/** 提交(新增/编辑) */
|
||||
const handleSave = useThrottleFn(async (formPayload: any) => {
|
||||
if (formSubmitting.value) return;
|
||||
setFormSubmitting(true);
|
||||
try {
|
||||
const res = isEdit.value
|
||||
? await updateNotice({ id: editingRecord.value?.id, ...formPayload })
|
||||
: await saveNotice(formPayload);
|
||||
|
||||
if (res.code == 200) {
|
||||
message.success(isEdit.value ? '编辑成功' : '新增成功');
|
||||
handleCloseForm();
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch {
|
||||
// 网络层已统一提示,不再重复 message.error
|
||||
} finally {
|
||||
setFormSubmitting(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
/** 删除 */
|
||||
const handleDelete = useThrottleFn(async (record: NoticeVO) => {
|
||||
try {
|
||||
const res = await deleteNotice(record.id);
|
||||
if (res.code == 200) {
|
||||
message.success('删除成功');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.msg || '删除失败');
|
||||
}
|
||||
} catch {
|
||||
// 网络层已统一提示,不再重复 message.error
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return {
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
columns,
|
||||
pagination,
|
||||
hasFilter,
|
||||
formVisible,
|
||||
editingRecord,
|
||||
isEdit,
|
||||
formSubmitting,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
handleAdd,
|
||||
handleEdit,
|
||||
handleCloseForm,
|
||||
handleSave,
|
||||
handleDelete,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AiOutlineSafetyCertificate,
|
||||
AiOutlineSetting,
|
||||
AiOutlineShoppingCart,
|
||||
AiOutlineSound,
|
||||
AiOutlineTeam,
|
||||
AiOutlineTransaction,
|
||||
AiOutlineTrophy,
|
||||
@@ -35,6 +36,7 @@ const ROUTE_ICON_MAP: Record<string, IconType> = {
|
||||
AiOutlineUser,
|
||||
AiOutlineSafetyCertificate,
|
||||
AiOutlineAudit,
|
||||
AiOutlineSound,
|
||||
};
|
||||
|
||||
export type RouteIconName = keyof typeof ROUTE_ICON_MAP;
|
||||
|
||||
Reference in New Issue
Block a user