Просмотр исходного кода

✨ feat(admin): 添加政策资讯管理功能

- 在菜单中添加政策资讯管理入口
- 创建政策资讯管理页面,包含列表展示、搜索筛选、创建编辑等功能
- 添加政策资讯路由配置
- 完善政策资讯API,添加创建者和更新者追踪
- 扩展政策资讯实体,增加创建者和更新者字段

📝 docs(policy-news): 添加政策资讯相关文档

- 完善政策资讯实体注释
- 添加API参数说明文档

🔧 chore(policy-news): 优化政策资讯数据处理

- 调整政策资讯列表分页逻辑
- 优化图片上传处理流程
yourname 7 месяцев назад
Родитель
Сommit
1fdf8b46d3

+ 8 - 0
src/client/admin/menu.tsx

@@ -16,6 +16,7 @@ import {
   FolderOutlined,
   FundViewOutlined,
   RobotOutlined,
+  NotificationOutlined,
 } from '@ant-design/icons';
 
 export interface MenuItem {
@@ -157,6 +158,13 @@ export const useMenu = () => {
       path: '/admin/ai-agents',
       permission: 'ai-agent:manage'
     },
+    {
+      key: 'policy-news',
+      label: '政策资讯发布',
+      icon: <NotificationOutlined />,
+      path: '/admin/policy-news',
+      permission: 'policy-news:manage'
+    },
   ];
 
   // 用户菜单项

+ 696 - 0
src/client/admin/pages/PolicyNewsPage.tsx

@@ -0,0 +1,696 @@
+import React, { useState, useRef, useEffect } from 'react';
+import {
+  Button,
+  message,
+  Popconfirm,
+  Space,
+  Image,
+  Tag,
+  Card,
+  Row,
+  Col,
+  Statistic,
+  Table,
+  Form,
+  Input,
+  Select,
+  DatePicker,
+  Switch,
+  Modal,
+  Upload,
+  InputNumber,
+} from 'antd';
+import {
+  PlusOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  EyeOutlined,
+  StarOutlined,
+  StarFilled,
+  UploadOutlined,
+} from '@ant-design/icons';
+import { useRequest } from 'ahooks';
+import dayjs from 'dayjs';
+import { policyNewsClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { uploadFile } from '@/client/utils/minio';
+import ReactQuill from 'react-quill';
+import 'react-quill/dist/quill.snow.css';
+
+const { Search } = Input;
+const { Option } = Select;
+const { RangePicker } = DatePicker;
+const { TextArea } = Input;
+
+// 类型定义
+type PolicyNewsItem = InferResponseType<typeof policyNewsClient.$get, 200>['data'][0];
+type CreatePolicyNewsRequest = InferRequestType<typeof policyNewsClient.$post>['json'];
+type UpdatePolicyNewsRequest = InferRequestType<typeof policyNewsClient[':id']['$put']>['json'];
+
+interface PolicyNewsFormData extends Omit<CreatePolicyNewsRequest, 'publishTime' | 'images'> {
+  publishTime: dayjs.Dayjs;
+  images?: string[];
+}
+
+const PolicyNewsPage: React.FC = () => {
+  const [loading, setLoading] = useState(false);
+  const [modalVisible, setModalVisible] = useState(false);
+  const [editingRecord, setEditingRecord] = useState<PolicyNewsItem | null>(null);
+  const [content, setContent] = useState('');
+  const [data, setData] = useState<PolicyNewsItem[]>([]);
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+  const [searchParams, setSearchParams] = useState({
+    keyword: '',
+    category: '',
+    isFeatured: undefined,
+  });
+
+  const [form] = Form.useForm();
+
+  // 获取统计数据
+  const { data: statsData } = useRequest(async () => {
+    const response = await policyNewsClient.$get({
+      query: { page: 1, pageSize: 1000 }
+    });
+    const result = await response.json();
+    return result;
+  });
+
+  // 计算统计数据
+  const stats = React.useMemo(() => {
+    if (!statsData?.data) return { total: 0, featured: 0, views: 0 };
+    return {
+      total: statsData.data.length,
+      featured: statsData.data.filter(item => item.isFeatured === 1).length,
+      views: statsData.data.reduce((sum, item) => sum + item.viewCount, 0)
+    };
+  }, [statsData]);
+
+  // 获取政策资讯列表
+  const fetchPolicyNews = async (params: any = {}) => {
+    setLoading(true);
+    try {
+      const response = await policyNewsClient.$get({
+        query: {
+          page: params.current || pagination.current,
+          pageSize: params.pageSize || pagination.pageSize,
+          keyword: searchParams.keyword || params.keyword,
+          category: searchParams.category || params.category,
+          isFeatured: searchParams.isFeatured ?? params.isFeatured,
+        }
+      });
+
+      if (!response.ok) {
+        throw new Error('获取政策资讯失败');
+      }
+
+      const result = await response.json();
+      setData(result.data);
+      setPagination({
+        current: params.current || pagination.current,
+        pageSize: params.pageSize || pagination.pageSize,
+        total: result.pagination.total,
+      });
+    } catch (error) {
+      message.error('获取政策资讯失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchPolicyNews();
+  }, [searchParams]);
+
+  // 处理表格分页、排序、筛选变化
+  const handleTableChange = (newPagination: any, filters: any, sorter: any) => {
+    const params = {
+      current: newPagination.current,
+      pageSize: newPagination.pageSize,
+      ...filters,
+      sortField: sorter.field,
+      sortOrder: sorter.order,
+    };
+    fetchPolicyNews(params);
+  };
+
+  // 处理搜索
+  const handleSearch = () => {
+    setPagination(prev => ({ ...prev, current: 1 }));
+    fetchPolicyNews({ current: 1 });
+  };
+
+  // 重置搜索
+  const handleReset = () => {
+    setSearchParams({
+      keyword: '',
+      category: '',
+      isFeatured: undefined,
+    });
+    setPagination(prev => ({ ...prev, current: 1 }));
+  };
+
+  // 删除政策资讯
+  const handleDelete = async (id: number) => {
+    try {
+      const response = await policyNewsClient[':id']['$delete']({
+        param: { id: id.toString() }
+      });
+
+      if (response.ok) {
+        message.success('删除成功');
+        fetchPolicyNews();
+      } else {
+        message.error('删除失败');
+      }
+    } catch (error) {
+      message.error('删除失败');
+    }
+  };
+
+  // 切换精选状态
+  const handleToggleFeatured = async (record: PolicyNewsItem) => {
+    try {
+      const response = await policyNewsClient[':id']['$put']({
+        param: { id: record.id.toString() },
+        json: { isFeatured: record.isFeatured === 1 ? 0 : 1 }
+      });
+
+      if (response.ok) {
+        message.success(record.isFeatured === 1 ? '取消精选成功' : '设为精选成功');
+        fetchPolicyNews();
+      } else {
+        message.error('操作失败');
+      }
+    } catch (error) {
+      message.error('操作失败');
+    }
+  };
+
+  // 处理表单提交
+  const handleSubmit = async (values: any) => {
+    try {
+      const formData = {
+        ...values,
+        publishTime: values.publishTime.toDate(),
+        images: values.images?.join(',') || '',
+        newsContent: content,
+      };
+
+      let response;
+      if (editingRecord) {
+        response = await policyNewsClient[':id']['$put']({
+          param: { id: editingRecord.id.toString() },
+          json: formData as UpdatePolicyNewsRequest,
+        });
+      } else {
+        response = await policyNewsClient.$post({
+          json: formData as CreatePolicyNewsRequest,
+        });
+      }
+
+      if (response.ok) {
+        message.success(editingRecord ? '更新成功' : '创建成功');
+        setModalVisible(false);
+        setEditingRecord(null);
+        setContent('');
+        form.resetFields();
+        fetchPolicyNews();
+      } else {
+        message.error(editingRecord ? '更新失败' : '创建失败');
+      }
+    } catch (error) {
+      message.error(editingRecord ? '更新失败' : '创建失败');
+    }
+  };
+
+  // 处理图片上传
+  const handleImageUpload = async (file: File) => {
+    try {
+      const url = await uploadFile(file, 'policy-news');
+      return url;
+    } catch (error) {
+      message.error('图片上传失败');
+      return '';
+    }
+  };
+
+  // 打开编辑模态框
+  const handleEdit = (record: PolicyNewsItem) => {
+    setEditingRecord(record);
+    setContent(record.newsContent);
+    
+    // 设置表单初始值
+    form.setFieldsValue({
+      ...record,
+      publishTime: dayjs(record.publishTime),
+      images: record.images?.split(',').filter(Boolean) || [],
+    });
+    
+    setModalVisible(true);
+  };
+
+  // 打开新建模态框
+  const handleCreate = () => {
+    setEditingRecord(null);
+    setContent('');
+    form.resetFields();
+    form.setFieldsValue({
+      publishTime: dayjs(),
+    });
+    setModalVisible(true);
+  };
+
+  // 处理取消
+  const handleCancel = () => {
+    setModalVisible(false);
+    setEditingRecord(null);
+    setContent('');
+    form.resetFields();
+  };
+
+  // 表格列配置
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      width: 80,
+      key: 'id',
+    },
+    {
+      title: '标题',
+      dataIndex: 'newsTitle',
+      ellipsis: true,
+      width: 200,
+      key: 'newsTitle',
+    },
+    {
+      title: '分类',
+      dataIndex: 'category',
+      width: 100,
+      key: 'category',
+      render: (text: string) => text && <Tag color="blue">{text}</Tag>,
+      filters: [
+        { text: '政策法规', value: '政策法规' },
+        { text: '养老政策', value: '养老政策' },
+        { text: '医疗政策', value: '医疗政策' },
+        { text: '社会服务', value: '社会服务' },
+        { text: '其他', value: '其他' },
+      ],
+    },
+    {
+      title: '摘要',
+      dataIndex: 'summary',
+      ellipsis: true,
+      width: 200,
+      key: 'summary',
+    },
+    {
+      title: '发布时间',
+      dataIndex: 'publishTime',
+      width: 150,
+      key: 'publishTime',
+      render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
+      sorter: (a: PolicyNewsItem, b: PolicyNewsItem) => 
+        dayjs(a.publishTime).valueOf() - dayjs(b.publishTime).valueOf(),
+    },
+    {
+      title: '阅读量',
+      dataIndex: 'viewCount',
+      width: 80,
+      key: 'viewCount',
+      sorter: (a: PolicyNewsItem, b: PolicyNewsItem) => a.viewCount - b.viewCount,
+    },
+    {
+      title: '图片',
+      dataIndex: 'images',
+      width: 80,
+      key: 'images',
+      render: (text: string) => {
+        if (!text) return null;
+        const images = text.split(',').filter(Boolean);
+        if (images.length === 0) return null;
+        return (
+          <Image
+            width={50}
+            height={50}
+            src={images[0]}
+            style={{ objectFit: 'cover', borderRadius: 4 }}
+          />
+        );
+      },
+    },
+    {
+      title: '精选',
+      dataIndex: 'isFeatured',
+      width: 80,
+      key: 'isFeatured',
+      render: (text: number, record: PolicyNewsItem) => (
+        <Tag color={text === 1 ? 'gold' : 'default'}>
+          {text === 1 ? '精选' : '普通'}
+        </Tag>
+      ),
+      filters: [
+        { text: '精选', value: 1 },
+        { text: '普通', value: 0 },
+      ],
+    },
+    {
+      title: '来源',
+      dataIndex: 'source',
+      width: 100,
+      key: 'source',
+      ellipsis: true,
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createdAt',
+      width: 150,
+      key: 'createdAt',
+      render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
+      sorter: (a: PolicyNewsItem, b: PolicyNewsItem) => 
+        dayjs(a.createdAt).valueOf() - dayjs(b.createdAt).valueOf(),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 150,
+      fixed: 'right' as const,
+      render: (_, record: PolicyNewsItem) => (
+        <Space>
+          <Button
+            type="link"
+            icon={<EyeOutlined />}
+            onClick={() => window.open(`/policy-news/${record.id}`, '_blank')}
+          >
+            预览
+          </Button>
+          <Button
+            type="link"
+            icon={<EditOutlined />}
+            onClick={() => handleEdit(record)}
+          >
+            编辑
+          </Button>
+          <Button
+            type="link"
+            icon={record.isFeatured === 1 ? <StarFilled /> : <StarOutlined />}
+            onClick={() => handleToggleFeatured(record)}
+          >
+            {record.isFeatured === 1 ? '取消' : '精选'}
+          </Button>
+          <Popconfirm
+            title="确定要删除这条政策资讯吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger icon={<DeleteOutlined />}>
+              删除
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+
+  // 富文本编辑器配置
+  const quillModules = {
+    toolbar: [
+      [{ header: [1, 2, 3, 4, 5, 6, false] }],
+      ['bold', 'italic', 'underline', 'strike'],
+      ['blockquote', 'code-block'],
+      [{ list: 'ordered' }, { list: 'bullet' }],
+      [{ script: 'sub' }, { script: 'super' }],
+      [{ indent: '-1' }, { indent: '+1' }],
+      [{ direction: 'rtl' }],
+      [{ size: ['small', false, 'large', 'huge'] }],
+      [{ header: [1, 2, 3, 4, 5, 6, false] }],
+      [{ color: [] }, { background: [] }],
+      [{ font: [] }],
+      [{ align: [] }],
+      ['clean'],
+      ['link', 'image', 'video'],
+    ],
+  };
+
+  // 自定义上传组件
+  const normFile = (e: any) => {
+    if (Array.isArray(e)) {
+      return e;
+    }
+    return e?.fileList;
+  };
+
+  return (
+    <div>
+      {/* 统计卡片 */}
+      <Row gutter={16} style={{ marginBottom: 16 }}>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="政策资讯总数"
+              value={stats.total}
+              valueStyle={{ color: '#3f8600' }}
+            />
+          </Card>
+        </Col>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="精选资讯"
+              value={stats.featured}
+              valueStyle={{ color: '#faad14' }}
+            />
+          </Card>
+        </Col>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="总阅读量"
+              value={stats.views}
+              valueStyle={{ color: '#1890ff' }}
+            />
+          </Card>
+        </Col>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="今日发布"
+              value={statsData?.data?.filter(
+                item => dayjs(item.createdAt).isSame(dayjs(), 'day')
+              ).length || 0}
+              valueStyle={{ color: '#722ed1' }}
+            />
+          </Card>
+        </Col>
+      </Row>
+
+      {/* 搜索区域 */}
+      <Card style={{ marginBottom: 16 }}>
+        <Row gutter={16}>
+          <Col span={6}>
+            <Search
+              placeholder="搜索标题或摘要"
+              allowClear
+              value={searchParams.keyword}
+              onChange={(e) => setSearchParams(prev => ({ ...prev, keyword: e.target.value }))}
+              onSearch={handleSearch}
+            />
+          </Col>
+          <Col span={4}>
+            <Select
+              placeholder="选择分类"
+              allowClear
+              value={searchParams.category}
+              onChange={(value) => setSearchParams(prev => ({ ...prev, category: value }))}
+              style={{ width: '100%' }}
+            >
+              <Option value="政策法规">政策法规</Option>
+              <Option value="养老政策">养老政策</Option>
+              <Option value="医疗政策">医疗政策</Option>
+              <Option value="社会服务">社会服务</Option>
+              <Option value="其他">其他</Option>
+            </Select>
+          </Col>
+          <Col span={4}>
+            <Select
+              placeholder="是否精选"
+              allowClear
+              value={searchParams.isFeatured}
+              onChange={(value) => setSearchParams(prev => ({ ...prev, isFeatured: value }))}
+              style={{ width: '100%' }}
+            >
+              <Option value={1}>精选</Option>
+              <Option value={0}>普通</Option>
+            </Select>
+          </Col>
+          <Col span={6}>
+            <Space>
+              <Button type="primary" onClick={handleSearch}>搜索</Button>
+              <Button onClick={handleReset}>重置</Button>
+              <Button
+                type="primary"
+                onClick={handleCreate}
+                icon={<PlusOutlined />}
+              >
+                新建政策资讯
+              </Button>
+            </Space>
+          </Col>
+        </Row>
+      </Card>
+
+      {/* 表格 */}
+      <Table
+        rowKey="id"
+        loading={loading}
+        columns={columns}
+        dataSource={data}
+        pagination={{
+          ...pagination,
+          showQuickJumper: true,
+          showSizeChanger: true,
+          showTotal: (total) => `共 ${total} 条记录`,
+        }}
+        onChange={handleTableChange}
+        scroll={{ x: 1200 }}
+      />
+
+      {/* 编辑/新建模态框 */}
+      <Modal
+        title={editingRecord ? '编辑政策资讯' : '新建政策资讯'}
+        open={modalVisible}
+        onCancel={handleCancel}
+        width={800}
+        footer={null}
+        destroyOnClose
+      >
+        <Form
+          form={form}
+          onFinish={handleSubmit}
+          layout="vertical"
+          initialValues={{
+            publishTime: dayjs(),
+            images: [],
+          }}
+        >
+          <Form.Item
+            name="newsTitle"
+            label="资讯标题"
+            rules={[{ required: true, message: '请输入资讯标题' }]}
+          >
+            <Input placeholder="请输入政策资讯标题" />
+          </Form.Item>
+
+          <Form.Item
+            name="category"
+            label="资讯分类"
+            rules={[{ required: true, message: '请选择分类' }]}
+          >
+            <Select placeholder="请选择分类">
+              <Option value="政策法规">政策法规</Option>
+              <Option value="养老政策">养老政策</Option>
+              <Option value="医疗政策">医疗政策</Option>
+              <Option value="社会服务">社会服务</Option>
+              <Option value="其他">其他</Option>
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            name="source"
+            label="资讯来源"
+          >
+            <Input placeholder="请输入资讯来源" />
+          </Form.Item>
+
+          <Form.Item
+            name="summary"
+            label="资讯摘要"
+          >
+            <TextArea
+              rows={3}
+              maxLength={500}
+              showCount
+              placeholder="请输入资讯摘要"
+            />
+          </Form.Item>
+
+          <Form.Item
+            name="publishTime"
+            label="发布时间"
+            rules={[{ required: true, message: '请选择发布时间' }]}
+          >
+            <DatePicker showTime style={{ width: '100%' }} />
+          </Form.Item>
+
+          <Form.Item
+            name="isFeatured"
+            label="设为精选"
+            valuePropName="checked"
+          >
+            <Switch checkedChildren="是" unCheckedChildren="否" />
+          </Form.Item>
+
+          <Form.Item
+            name="images"
+            label="资讯图片"
+            valuePropName="fileList"
+            getValueFromEvent={normFile}
+          >
+            <Upload
+              name="file"
+              multiple
+              maxCount={5}
+              accept="image/*"
+              customRequest={async ({ file, onSuccess, onError }) => {
+                try {
+                  const url = await handleImageUpload(file as File);
+                  if (url) {
+                    onSuccess?.(url);
+                  } else {
+                    onError?.(new Error('上传失败'));
+                  }
+                } catch (error) {
+                  onError?.(error as Error);
+                }
+              }}
+              listType="picture-card"
+            >
+              <div>
+                <PlusOutlined />
+                <div style={{ marginTop: 8 }}>上传</div>
+              </div>
+            </Upload>
+          </Form.Item>
+
+          <Form.Item label="资讯内容">
+            <ReactQuill
+              theme="snow"
+              value={content}
+              onChange={setContent}
+              modules={quillModules}
+              style={{ height: 300 }}
+              placeholder="请输入政策资讯的详细内容..."
+            />
+          </Form.Item>
+
+          <Form.Item>
+            <Space>
+              <Button type="primary" htmlType="submit">
+                {editingRecord ? '更新' : '创建'}
+              </Button>
+              <Button onClick={handleCancel}>取消</Button>
+            </Space>
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};
+
+export default PolicyNewsPage;

+ 6 - 0
src/client/admin/routes.tsx

@@ -17,6 +17,7 @@ import SilverKnowledgeDetailPage from './pages/SilverKnowledgeDetailPage';
 import KnowledgeCategories from './pages/KnowledgeCategories';
 import BigScreenDashboard from './pages/BigScreenDashboard';
 import AIAgents from './pages/AIAgents';
+import PolicyNewsPage from './pages/PolicyNewsPage';
 
 export const router = createBrowserRouter([
   {
@@ -99,6 +100,11 @@ export const router = createBrowserRouter([
         element: <AIAgents />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'policy-news',
+        element: <PolicyNewsPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,

+ 5 - 1
src/server/api/policy-news/index.ts

@@ -9,7 +9,11 @@ const policyNewsRoutes = createCrudRoutes({
   getSchema: PolicyNewsSchema,
   listSchema: PolicyNewsSchema,
   searchFields: ['newsTitle', 'newsContent', 'summary', 'source', 'category'],
-  middleware: [authMiddleware]
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
 });
 
 export default policyNewsRoutes;

+ 9 - 1
src/server/modules/silver-users/policy-news.entity.ts

@@ -41,6 +41,12 @@ export class PolicyNews {
 
   @UpdateDateColumn({ name: 'updated_at' })
   updatedAt!: Date;
+
+  @Column({ name: 'created_by', type: 'int', nullable: true, comment: '创建用户ID' })
+  createdBy!: number | null;
+
+  @Column({ name: 'updated_by', type: 'int', nullable: true, comment: '更新用户ID' })
+  updatedBy!: number | null;
 }
 
 // 基础Schema定义
@@ -57,7 +63,9 @@ export const PolicyNewsSchema = z.object({
   isFeatured: z.coerce.number().int().min(0).max(1).openapi({ description: '是否精选', example: 1 }),
   isDeleted: z.coerce.number().int().min(0).max(1).openapi({ description: '删除状态', example: 0 }),
   createdAt: z.date().openapi({ description: '创建时间', example: '2024-01-15T08:00:00Z' }),
-  updatedAt: z.date().openapi({ description: '更新时间', example: '2024-01-15T08:00:00Z' })
+  updatedAt: z.date().openapi({ description: '更新时间', example: '2024-01-15T08:00:00Z' }),
+  createdBy: z.number().int().positive().nullable().openapi({ description: '创建用户ID', example: 1 }),
+  updatedBy: z.number().int().positive().nullable().openapi({ description: '更新用户ID', example: 1 })
 });
 
 // 创建DTO