快乐的梦鱼 1 mesiac pred
rodič
commit
e57370e175

+ 68 - 0
server/api/article/[id].ts

@@ -0,0 +1,68 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+import { IChannel } from '../channel/[id]';
+
+export interface IArticle {
+  id: number;
+  model_id: number;
+  channel_id: number;
+  title: string;
+  desc: string;
+  image: string,
+  images: string;
+  seotitle: string;
+  keywords: string;
+  description: string;
+  tags: string;
+  diyname: string;
+  publishtime: number;
+  createtime: number;
+  views: number;
+  content: string;
+  channel: IChannel;
+}
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<IArticle>>>(async (event) => {
+  try {
+    const id = event.context.params?.id;
+    if (!id)
+      return createErrorResponse('分类ID不能为空');
+    const article = await DB.table('pr_cms_archives')
+            .where('id', id)
+            .where('status', 'normal')
+            .first();
+    if (!article) 
+      return createErrorResponse('文章不存在');
+
+    const channel = await DB.table('pr_cms_channel')
+            .where('id', article.channel_id)
+            .first();
+    if (!channel)
+      return createErrorResponse('分类不存在');
+    article.channel = channel;
+
+    // 2. 通过model_id从pr_cms_model表中获取table字段
+    const model = await DB.table('pr_cms_model')
+      .where('id', article.model_id)
+      .select('table')
+      .first();
+    if (!model)
+      return createErrorResponse('分类不存在');
+        
+    // 3. 通过table指定的表通过id查出content
+    const content = await DB.table(`pr_${model.table}`)
+      .where('id', id)
+      .select('content')
+      .first();
+    
+    // 4. 合并返回结果
+    if (content && content['content']) {
+      article.content = content['content'];
+    }
+
+    return createSuccessResponse(article);
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 29 - 0
server/api/article/byChannel.ts

@@ -0,0 +1,29 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+import { CommonPageResult } from '~~/server/db/CommonModel';
+import type { IArticle } from './[id]';
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<CommonPageResult<IArticle>>>>(async (event) => {
+  try {  
+    const query = getQuery(event);
+    const page = query.page as string;
+    const pageSize = query.pageSize as string;
+    const channelId = query.channelId as string;
+    if (!channelId)
+      return createErrorResponse('分类ID不能为空');
+    const articles = await DB.table('pr_cms_archives')
+            .where('channel_id', channelId)
+            .where('status', 'normal')
+            .orderBy('weigh', 'desc')
+            .orderBy('publishtime', 'desc')
+            .orderBy('createtime', 'desc')
+            .orderBy('id', 'asc')
+            .paginate(Number(page), Number(pageSize));
+    if (!articles) 
+      return createErrorResponse('文章不存在');
+    return createSuccessResponse(articles);
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 47 - 0
server/api/article/byChannelName.ts

@@ -0,0 +1,47 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+import { CommonPageResult, ICommonPageResult } from '~~/server/db/CommonModel';
+import type { IArticle } from './[id]';
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<ICommonPageResult<IArticle> & {
+  channel_id: number;
+}>>>(async (event) => {
+  try {  
+    const query = getQuery(event);
+    const page = Number(query.page as string) || 1;
+    const pageSize = Number(query.pageSize as string) || 10;
+    const channelName = query.channelName as string;
+    if (!channelName)
+      return createErrorResponse('分类名称不能为空');
+    // 1. 从pr_cms_channel表中通过name查询channel_id
+    const channel = await DB.table('pr_cms_channel')
+      .where('name', channelName)
+      .where('status', 'normal')
+      .select('id')
+      .first();
+    // 如果没有找到对应的频道,返回空数组
+    if (!channel) 
+      return createSuccessResponse(new CommonPageResult<IArticle>(undefined, [], page, pageSize, 0));
+        
+    // 2. 从pr_cms_archives表中通过channel_id查询文章
+    const channelId = channel.id;
+    const articles = await DB.table('pr_cms_archives')
+            .where('channel_id', channelId)
+            .where('status', 'normal')
+            .orderBy('weigh', 'desc')
+            .orderBy('publishtime', 'desc')
+            .orderBy('createtime', 'desc')
+            .orderBy('id', 'asc')
+            .paginate(page, pageSize);
+    if (!articles) 
+      return createErrorResponse('文章不存在');
+
+    return createSuccessResponse({
+      channel_id: channelId,
+      ...articles
+    });
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 27 - 0
server/api/article/recommend.ts

@@ -0,0 +1,27 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+import { CommonPageResult } from '~~/server/db/CommonModel';
+import type { IArticle } from './[id]';
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<IArticle[]>>>(async (event) => {
+  try {  
+    const query = getQuery(event);
+    const pageSize = query.pageSize as string;
+
+     // 从pr_cms_archives表中查询flag包含recommend的文章
+    // 先按weigh降序排序,再按publishtime降序排序
+    const articles = await DB.table('pr_cms_archives')
+        .where('status', 'normal')
+        .where('flag', 'like', '%recommend%')
+        .orderBy('weigh','desc')
+        .orderBy('publishtime','desc')
+        .orderBy('createtime','desc')
+        .limit(0, Number(pageSize))
+        .get();
+
+    return createSuccessResponse(articles);
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 30 - 0
server/api/article/search.ts

@@ -0,0 +1,30 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+import { ICommonPageResult } from '~~/server/db/CommonModel';
+import type { IArticle } from './[id]';
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<ICommonPageResult<IArticle>>>>(async (event) => {
+  try {  
+    const query = getQuery(event);
+    const page = Number(query.page as string) || 1;
+    const pageSize = Number(query.pageSize as string) || 10;
+    const search = query.search as string;
+        
+    // 2. 从pr_cms_archives表中通过channel_id查询文章
+    const articles = await DB.table('pr_cms_archives')
+            .where('status', 'normal')
+            .where('title', 'like', `%${search}%`)
+            .orderBy('weigh', 'desc')
+            .orderBy('publishtime', 'desc')
+            .orderBy('createtime', 'desc')
+            .orderBy('id', 'asc')
+            .paginate(page, pageSize);
+    if (!articles) 
+      return createErrorResponse('文章不存在');
+
+    return createSuccessResponse(articles);
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 27 - 0
server/api/carousel.ts

@@ -0,0 +1,27 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+
+export interface ICarousel {
+  id: number;
+  name: string;
+  status: string;
+  type: string;
+  title: string;
+  image: string;
+  url: string;
+  weigh: number;
+  content: string;
+}
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<ICarousel[]>>>(async (event) => {
+  try {
+    return createSuccessResponse(await DB.table('pr_cms_block')
+            .where('name', 'banner')
+            .where('status', 'normal')
+            .orderBy('weigh', 'desc')
+            .get());
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 58 - 0
server/api/channel/[id].ts

@@ -0,0 +1,58 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+
+export interface IChannel {
+  id: number;
+  model_id: number;
+  parent_id: number;
+  name: string;
+  type: 'list'|'link',
+  url: string;
+  outlink: string;
+  diyname: string;
+}
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<IChannel & {
+  childs: IChannel[];
+  parents: IChannel[];
+}>>>(async (event) => {
+  try {
+    const id = event.context.params?.id;
+    if (!id)
+      return createErrorResponse('分类ID不能为空');
+    
+    const category = await DB.table('pr_cms_channel')
+            .where('id', id)
+            .where('status', 'normal')
+            .first();
+    if (!category) 
+      return createErrorResponse('分类不存在');
+
+    const childs = await DB.table('pr_cms_channel')
+      .where('status', 'normal')
+      .where('parent_id', id)
+      .orderBy('weigh', 'desc')
+      .orderBy('id', 'desc')
+      .get();
+
+    const parents : IChannel[] = [];
+    let parentId = category.parent_id;
+    while (parentId) {
+      const parent = await DB.table('pr_cms_channel')
+        .where('id', parentId)
+        .where('status', 'normal')
+        .first();
+      if (!parent) 
+        break;
+      parents.push(parent);
+      parentId = parent.parent_id;
+    }
+
+    category.parents = parents;
+    category.childs = childs;
+    return createSuccessResponse(category);
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 33 - 0
server/api/channel/byName.ts

@@ -0,0 +1,33 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+import type { IChannel } from './[id]';
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<IChannel & {
+  childs: IChannel[];
+}>>>(async (event) => {
+  try {  
+    const query = getQuery(event);
+    const name = query.name as string;
+    if (!name)
+      return createErrorResponse('分类名称不能为空');
+    
+    const category = await DB.table('pr_cms_channel')
+            .where('name', name)
+            .where('status', 'normal')
+            .first();
+    if (!category) 
+      return createErrorResponse('分类不存在');
+
+    const childs = await DB.table('pr_cms_channel')
+      .where('status', 'normal')
+      .where('parent_id', category.id)
+      .orderBy('weigh', 'desc')
+      .orderBy('id', 'desc')
+      .get();
+    category.childs = childs;
+    return createSuccessResponse(category);
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 2 - 9
server/api/channel/nav.ts

@@ -1,16 +1,9 @@
 import { defineEventHandler, EventHandlerRequest } from 'h3';
 import { DB } from '~~/server/db/DB';
 import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+import { IChannel } from './[id]';
 
-export default defineEventHandler<EventHandlerRequest, Promise<IResponse<{
-  id: number;
-  model_id: number;
-  name: string;
-  type: 'list'|'link',
-  url: string;
-  outlink: string;
-  diyname: string;
-}[]>>>(async (event) => {
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<IChannel[]>>>(async (event) => {
   try {
     return createSuccessResponse(await DB.table('pr_cms_channel')
             .where('status', 'normal')

+ 1 - 1
server/db/QueryGenerator.ts

@@ -197,7 +197,7 @@ export class QueryGenerator<O extends CommonModel = any> {
   private tableSetCommonModel: NewCommonModel<O>|undefined;
 
   public enableLog() {
-    this.enableLogSQL = true;
+    this.enableLogSQL = false;
     return this;
   }
 

+ 0 - 4
src/App.vue

@@ -19,7 +19,6 @@
 <script setup lang="ts">
 import { onMounted, watch } from 'vue';
 import { useRoute } from 'vue-router'
-import { useAuthStore } from './stores/auth';
 import { initAMapApiLoader } from '@vuemap/vue-amap';
 import { registryConvert } from './common/ConvertRgeistry'
 import zhCN from 'ant-design-vue/es/locale/zh_CN';
@@ -34,12 +33,9 @@ if (import.meta.client) {
 } 
 registryConvert();
 
-const authStore = useAuthStore();
-
 onMounted(() => {
   if (import.meta.server)
     return;
-  authStore.loadLoginState();
 });
 
 const route = useRoute();

+ 0 - 12
src/api/NotConfigue.ts

@@ -1,12 +0,0 @@
-import { DataModel } from '@imengyu/js-request-transform';
-import { AppServerRequestModule } from './RequestModules';
-
-export class CommonApi extends AppServerRequestModule<DataModel> {
-
-  constructor() {
-    super();
-    this.config.modelClassCreator = DataModel;
-  }
-}
-
-export default new CommonApi();

+ 0 - 207
src/api/RequestModules.ts

@@ -1,207 +0,0 @@
-
-/**
- * 这里写的是业务相关的:
- * * 请求数据处理函数。
- * * 自定义请求模块。
- * * 自定义错误报告处理函数。
- */
-
-import AppCofig from "@/common/config/AppCofig";
-import ApiCofig from "@/common/config/ApiCofig";
-import { 
-  RequestApiConfig,
-  RequestApiError, RequestApiResult, type RequestApiErrorType, 
-  RequestCoreInstance, RequestOptions, 
-  defaultResponseDataGetErrorInfo, defaultResponseDataHandlerCatch, 
-  RequestResponse,
-  WebFetchImplementer,
-  StringUtils,
-  appendGetUrlParams, 
-  appendPostParams,
-} from "@imengyu/imengyu-utils";
-import type { DataModel, KeyValue, NewDataModel } from "@imengyu/js-request-transform";
-import { useAuthStore } from "@/stores/auth";
-import { Modal } from "ant-design-vue";
-
-/**
- * 不报告错误的 code
- */
-const notReportErrorCode = [401] as number[];
-const notReportMessages = [
-  /请授权绑定手机号/g,
-] as RegExp[];
-function matchNotReportMessage(str: string) {
-  for (let i = 0; i < notReportMessages.length; i++) {
-    if (notReportMessages[i]?.test(str))
-      return true;
-  }
-  return false;
-}
-
-//请求拦截器
-function requestInceptor(url: string, req: RequestOptions) {
-  //获取store中的token,追加到头;
-  const autoStore = useAuthStore();
-  if (StringUtils.isNullOrEmpty((req.header as KeyValue).token as string)) {
-    req.header['token'] = autoStore.token;
-    req.header['__token__'] = autoStore.token;
-  }
-  if (req.method == 'GET') {
-    //追加GET参数
-    url = appendGetUrlParams(url, 'main_body_id', ApiCofig.mainBodyId);
-  } else {
-    req.data = appendPostParams(req.data,'main_body_id', ApiCofig.mainBodyId);
-  }
-  return { newUrl: url, newReq: req };
-}
-//响应数据处理函数
-function responseDataHandler<T extends DataModel>(response: RequestResponse, req: RequestOptions, resultModelClass: NewDataModel|undefined, instance: RequestCoreInstance<T>, apiName: string | undefined): Promise<RequestApiResult<T>> {
-  return new Promise<RequestApiResult<T>>((resolve, reject) => {
-    const method = req.method || 'GET';
-    response.json().then((json) => {
-      if (response.ok) {
-        if (!json) {
-          reject(new RequestApiError(
-            'businessError',
-            '后端未返回数据',
-            '',
-            response.status,
-            null,
-            null,
-            req,
-            apiName,
-            response.url
-          ));
-          return;
-        }
-
-        //code == 0 错误
-        if (json.code === 0) {
-          handleError();
-          return;
-        }
-
-        //处理后端的数据
-        let message = '未知错误';
-        let data = {} as any;
-
-        //后端返回格式不统一,所以在这里处理格式
-        if (typeof json.data === 'object') {
-          data = json.data;
-          message = json.data?.msg || response.statusText;
-        }
-        else {
-          //否则返回上层对象
-          data = json;
-          message = json.msg || response.statusText;
-        }
-
-        resolve(new RequestApiResult(
-          resultModelClass ?? instance.config.modelClassCreator,
-          json?.code || response.status,
-          message,
-          data,
-          json
-        ));
-      }
-      else {
-        handleError();
-      }
-
-      function handleError() {
-        let errType : RequestApiErrorType = 'unknow';
-        let errString = '';
-        let errCodeStr = '';
-
-        if (typeof json.message === 'string') 
-          errString = json.message;
-        if (typeof json.msg === 'string') 
-          errString += json.msg;
-
-        if (StringUtils.isStringAllEnglish(errString))
-          errString = '服务器返回:' + errString;
-
-        //错误处理
-        if (errString) {
-          //如果后端有返回错误信息,则收集错误信息并返回
-          errType = 'businessError';
-          if (typeof json.data === 'object' && json.data?.errmsg) {
-            errString += '\n' + json.data.errmsg;
-          }
-          if (typeof json.errors === 'object') {
-            for (const key in json.errors) {
-              if (Object.prototype.hasOwnProperty.call(json.errors, key)) {
-                errString += '\n' + json.errors[key];
-              }
-            }
-          }
-        } else {
-          const res = defaultResponseDataGetErrorInfo(response, json);
-          errType = res.errType;
-          errString = res.errString;
-          errCodeStr = res.errCodeStr;
-        }
-
-        reject(new RequestApiError(
-          errType,
-          errString,
-          errCodeStr,
-          response.status,
-          null,
-          null,
-          req,
-          apiName,
-          response.url
-        ));
-      }
-    }).catch((err) => {
-      //错误统一处理
-      defaultResponseDataHandlerCatch(method, req, response, null, err, apiName, response.url, reject, instance);
-    });
-  });
-}
-//错误报告处理
-function responseErrReoprtInceptor<T extends DataModel>(instance: RequestCoreInstance<T>, response: RequestApiError) {
-  return (
-    (response.errorType !== 'businessError' && response.errorType !== 'networkError') ||
-    notReportErrorCode.indexOf(response.code) >= 0 ||
-    matchNotReportMessage(response.errorMessage) === true
-  );
-}
-
-//错误报告处理
-export function reportError<T extends DataModel>(instance: RequestCoreInstance<T>, response: RequestApiError | Error) {
-  if (import.meta.env.DEV) {
-    if (response instanceof RequestApiError) {
-      
-    } else {
-    }
-  } else {    
-    let errMsg = '';
-    if (response instanceof RequestApiError)
-      errMsg = response.errorMessage + '。';
-      
-    errMsg += '服务出现了异常,请稍后重试或联系客服。';
-    errMsg += '版本:' + AppCofig.version;
-
-    Modal.error({
-      title: '抱歉',
-      content: errMsg,
-    });
-}
-}
-
-/**
- * App服务请求模块
- */
-export class AppServerRequestModule<T extends DataModel> extends RequestCoreInstance<T> {
-  constructor() {
-    super(WebFetchImplementer);
-    this.config.baseUrl = ApiCofig.serverProd;
-    this.config.errCodes = []; //
-    this.config.requestInceptor = requestInceptor;
-    this.config.responseDataHandler = responseDataHandler;
-    this.config.responseErrReoprtInceptor = responseErrReoprtInceptor;
-    this.config.reportError = reportError;
-  }
-}

+ 0 - 15
src/api/Utils.ts

@@ -1,15 +0,0 @@
-export function transformSomeToArray(source: any) {
-  if (typeof source === 'string') 
-    return source.split(','); 
-  if (typeof source === 'object') {
-    if (source instanceof Array)
-      return source; 
-    else {
-      const arr = [];
-      for (const key in source)
-        arr.push(source[key]);
-      return arr;
-    }
-  }
-  return source;
-}

+ 0 - 68
src/api/auth/UserApi.ts

@@ -1,68 +0,0 @@
-import { DataModel } from '@imengyu/js-request-transform';
-import { AppServerRequestModule } from '../RequestModules';
-
-
-export class LoginResult extends DataModel<LoginResult> {
-  constructor() {
-    super(LoginResult, "登录结果");
-    this._convertTable = {
-      userInfo: { clientSide: 'object', clientSideChildDataModel: UserInfo },
-    };
-    this._nameMapperServer = {
-      'userinfo': 'userInfo',
-      'mainBodyUserInfo': 'userInfo',
-    }
-    this._afterSolveServer = () => {
-      if (this.mainBodyUserInfo) {
-        this.userInfo.token = this.mainBodyUserInfo.token;
-      }
-    };
-  }
-  userInfo !:UserInfo;
-  mainBodyUserInfo?:UserInfo;
-}
-export class UserInfo extends DataModel<UserInfo> {
-  constructor() {
-    super(UserInfo, "用户信息");
-    this.setNameMapperCase('Camel', 'Snake');
-    this._convertTable = {
-      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
-    }
-  }
-
-  expiresIn = 0;
-  id = 0;
-  userId = 0;
-  mobile = '';
-  nickname = '';
-  avatar = '';
-  username = '';
-  token = '';
-}
-
-export class UserApi extends AppServerRequestModule<DataModel> {
-
-  constructor() {
-    super();
-  }
-  async loginAdmin(data?: {
-    account: string,
-    password: string,
-  }) {
-    const form = new FormData();
-    form.append('account', data?.account || '');
-    form.append('password', data?.password || '');
-    return (await this.post('/user/adminLogin', form, '登录', undefined, LoginResult)).data as LoginResult;
-  }
-  async getUserInfo(main_body_user_id: number) {
-    return (await this.post('/content/main_body_user/getMainBodyUser', {
-      main_body_user_id,
-    }, '获取用户信息', undefined, UserInfo)).data as UserInfo;
-  }
-  async refresh() {
-    return (await this.post('/content/main_body_user/refreshUser', {
-    }, '刷新用户', undefined, LoginResult)).data as LoginResult;
-  }
-}
-
-export default new UserApi();

BIN
src/assets/images/board1/1.jpg


BIN
src/assets/images/board1/2.jpg


BIN
src/assets/images/board1/3.jpg


BIN
src/assets/images/board1/4.jpg


BIN
src/assets/images/board1/5.jpg


BIN
src/assets/images/board1/6.jpg


+ 9 - 0
src/assets/scss/main.scss

@@ -215,6 +215,10 @@ nav.main-nav {
   background-color: var(--color-box);
   border-radius: 10px;
   padding: 26px;
+
+  &.fill {
+    height: 100%;
+  }
 }
 .main-hr {
   border-color: var(--color-border);
@@ -641,6 +645,11 @@ footer {
       border-bottom: none;
     }
   }
+
+  .no-content {
+    padding: 15px 20px;
+    color: var(--color-text-secondary);
+  }
 }
 
 .pagination {

+ 9 - 1
src/components/NavBar.vue

@@ -33,10 +33,18 @@ const route = useRoute();
 
 const navItems = await useSSrSimpleDataLoader('navItems', async () => {
   const data = (await $fetch('/api/channel/nav')).data || [];
+
+  // 政策法规 特殊页
+  const lawsPage = data.find(item => item.name === '政策法规');
+  if (lawsPage) {
+    lawsPage.type = 'link';
+    lawsPage.outlink = '/channel/laws/?id=' + lawsPage.id;
+  }
+
   return data.map(item => {
     return {
       ...item,
-      url: item.type === 'list' ? `/channel/${item.diyname}` : item.outlink,
+      url: item.type === 'list' ? `/channel/${item.id}` : item.outlink,
     }
   });
 });

+ 4 - 4
src/components/content/SimplePageContentLoader.vue

@@ -1,13 +1,13 @@
 <template>
   <div
     v-if="loader?.loadStatus.value == 'loading'"
-    style="min-height: 200rpx;display: flex;justify-content: center;align-items: center;"
+    style="min-height: 200px;display: flex;justify-content: center;align-items: center;"
   >
     <a-spin tip="加载中" />
   </div>
   <div
     v-else-if="loader?.loadStatus.value == 'error'"
-    style="min-height: 200rpx"
+    style="min-height: 200px"
   >
     <a-empty :description="loader.loadError.value" >
       <a-button  @click="handleRetry">重试</a-button>
@@ -17,8 +17,8 @@
     <slot />
   </template>
   <div
-    v-if="showEmpty || loader?.loadStatus.value == 'nomore'"
-    style="min-height: 200rpx"
+    v-if="showEmpty || loader?.loadStatus.value == 'empty'"
+    style="min-height: 200px"
     class="empty"
   >
     <a-empty :description="emptyView?.text ?? '暂无数据'">

+ 66 - 0
src/components/content/SimplePagination.vue

@@ -0,0 +1,66 @@
+<template>
+  <nav aria-label="List Page navigation">
+    <ul class="pagination mt-4">
+      <li class="prev">
+        <router-link :to="{
+          path: route.path,
+          query: {
+            ...route.query,
+            page: (currPage > 1 ? currPage - 1 : 1),
+          }
+        }">
+          &lt;
+        </router-link>
+      </li>
+      <li v-for="i in showItems" :key="i" :class="{ 'active': i == currPage }">
+        <router-link :to="{
+          path: route.path,
+          query: {
+            ...route.query,
+            page: i,
+          }
+        }">
+          {{ i }}
+        </router-link>
+      </li>
+      <li class="next">
+        <router-link :to="{
+          path: route.path,
+          query: {
+            ...route.query,
+            page: Math.max(currPage < allPage ? currPage + 1 : allPage, 1),
+          }
+        }">
+          &gt;
+        </router-link>
+      </li>
+    </ul>
+  </nav>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+const props = withDefaults(defineProps<{
+  currPage?: number,
+  allPage?: number,
+  maxCount?: number,
+}>(), {
+  currPage: 1,
+  allPage: 1,
+  maxCount: 10,
+});
+
+const route = useRoute();
+
+const showItems = computed(() => {
+  const items = []
+  const start = Math.max(1, props.currPage - Math.floor(props.maxCount / 2))
+  const end = Math.min(props.allPage, start + props.maxCount - 1)
+  for (let i = start; i <= end; i++) {
+    items.push(i)
+  }
+  return items
+})
+
+</script>

+ 1 - 1
src/composeable/LoaderCommon.ts

@@ -1,6 +1,6 @@
 import type { Ref } from "vue";
 
-export type LoaderLoadType = 'loading' | 'finished' | 'nomore' | 'error';
+export type LoaderLoadType = 'loading' | 'finished' | 'nomore' | 'error' | 'empty';
 
 export interface ILoaderCommon<P> {
   loadError: Ref<string>;

+ 1 - 1
src/composeable/SimpleDataLoader.ts

@@ -27,7 +27,7 @@ export function useSimpleDataLoader<T, P = any>(
       const res = (await loader(params ?? lastParams)) as T;
       content.value = res;
       if (Array.isArray(res) && emptyIfArrayEmpty && (res as any[]).length === 0)
-        loadStatus.value = 'nomore';
+        loadStatus.value = 'empty';
       else
         loadStatus.value = 'finished';
       loadError.value = '';

+ 3 - 3
src/composeable/SimplePagerDataLoader.ts

@@ -151,7 +151,7 @@ export async function useSSrSimplePagerDataLoader<T, P = any>(
   const list = ref<T[]>([]) as Ref<T[]>;
   const total = ref(0);
   const totalPages = computed(() => Math.ceil(total.value / getPageSize()));
-  const loadStatus = ref<LoaderLoadType>('finished');
+  const loadStatus = ref<LoaderLoadType>('loading');
   const loadError = ref('');
 
   if (error.value) {
@@ -161,7 +161,7 @@ export async function useSSrSimplePagerDataLoader<T, P = any>(
     list.value = data.value.data as any;
     total.value = data.value.total as any;
     loadError.value = '';
-    loadStatus.value = 'finished';
+    loadStatus.value = total.value > 0 ? 'finished' : 'empty';
   }
 
   function getPageSize() {
@@ -190,7 +190,7 @@ export async function useSSrSimplePagerDataLoader<T, P = any>(
       const res = (await loader(page.value, getPageSize(), lastParams));
       list.value = list.value.concat(res.data);
       total.value = res.total;
-      loadStatus.value = list.value.length > 0 ? 'finished' : 'nomore';
+      loadStatus.value = list.value.length > 0 ? (res.data.length > 0 ? 'finished' : 'nomore') : 'empty';
       loadError.value = '';
       loading = false;
     } catch(e) {

+ 121 - 4
src/pages/channel/[id].vue

@@ -10,7 +10,7 @@
     <!-- 轮播 -->
     <Carousel v-bind="carouselConfig" class="main-header-box small carousel-light">
       <Slide 
-        v-for="(item, key) in newsData.content.value"
+        v-for="(item, key) in carouselData.content.value"
         :key="key"
         class="main-header-box small"
       >
@@ -22,12 +22,84 @@
       </template>
     </Carousel>
 
+    <!-- 主要内容 -->
+    <div class="main-content">
+      <div class="container">
+        <div class="row">
+          <!-- 左侧导航 -->
+          <div class="col-12 col-sm-12 col-md-4 col-lg-3">
+            <div class="sidebar">
+              <div class="title">
+                  <h2>{{ channelName }}</h2>
+                </div>
+                <ul class="sidebar-menu">
+                  <li v-for="(item, key) in channelData.content.value?.childs" :key="key">
+                    <router-link :to="`/channel/${item.id}`" :class="{ 'active': item.id == channelId }">
+                      {{ item.name }}<i class="fa fa-arrow-right"></i>
+                    </router-link>
+                  </li>
+                  <li v-if="channelData.content.value?.parent_id !== 0">
+                    <router-link :to="`/channel/${channelData.content.value?.parent_id}`">
+                      <div>
+                        <Icon name="material-symbols:undo" />
+                        返回上一级
+                      </div>
+                    </router-link>
+                  </li>
+                  <li v-if="!channelData.content.value?.childs || channelData.content.value?.childs.length === 0" class="no-content">暂无相关子分类</li>
+                </ul>
+              </div>
+            </div>
+          
+          <!-- 右侧内容 -->
+          <div class="col-12 col-sm-12 col-md-8 col-lg-9">
+            <div class="content">
+              <div class="section-title">
+                <h2 class="icon">{{ channelName }}</h2>
+                
+                <nav aria-label="breadcrumb">
+                  <ol class="breadcrumb">
+                    <li class="breadcrumb-item"><router-link to="/">首页</router-link></li>
+                    <li v-for="(item, key) in channelData.content.value?.parents" :key="key" class="breadcrumb-item">
+                      <router-link :to="`/channel/${item.id}`">{{ item.name }}</router-link>
+                    </li>
+                    <li class="breadcrumb-item active" aria-current="page">{{ channelName }}</li>
+                  </ol>
+                </nav>
+              </div>
+              
+              <!-- 文章列表 -->
+              <SimplePageContentLoader :loader="articlesData">
+                <div class="news-list">
+                  <div v-for="(item, key) in articlesData.content.value?.items" :key="key" class="news-item">
+                    <router-link :to="'/page/' + item.id" class="title">{{ item.title }}</router-link>
+                    <span class="date">{{ DateUtils.formatDate(new Date(item.publishtime * 1000), 'yyyy-MM-dd') }}</span>
+                  </div>
+                  <div v-if="!articlesData.content.value || articlesData.content.value.empty" class="no-news">暂无相关文章</div>
+                </div>
+                <!-- 分页 -->
+                <SimplePagination
+                  v-if="articlesData.content.value" 
+                  :currPage="articlesData.content.value.pageIndex"
+                  :allPage="articlesData.content.value.allPage"
+                  :maxCount="10"
+                />
+              </SimplePageContentLoader>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
+import { computed } from 'vue';
 import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
 import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { DateUtils } from '@imengyu/imengyu-utils';
+import type { IChannel } from '~~/server/api/channel/[id]';
+import SimplePagination from '~/components/content/SimplePagination.vue';
 
 const carouselConfig = {
   itemsToShow: 1,
@@ -36,14 +108,59 @@ const carouselConfig = {
 }
 
 const route = useRoute();
-const channelName = route.params.id;
+const channelId = parseInt(route.params.id as string);
 
-const newsData = await useSSrSimpleDataLoader('news', async () => {
-  throw new Error('newsData is not implemented');
+const channelData = await useSSrSimpleDataLoader('channel' + channelId, async () => {
+  const res = await $fetch(`/api/channel/${channelId}`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data as IChannel & {
+    childs: IChannel[];
+    parents: IChannel[];
+  };
 });
+const carouselData = await useSSrSimpleDataLoader('carousel' + channelId, async () => {
+  const res = await $fetch(`/api/carousel`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+const articlesData = await useSSrSimpleDataLoader('articles' + channelId, async () => {
+  const res = await $fetch('/api/article/byChannel', {
+    method: 'GET',
+    query: { 
+      channelId: channelId,
+      page: route.query.page || 1,
+      pageSize: 10,
+    }
+  });
+  if (!res.status)
+    throw new Error(res.message);
 
+  if (res.data?.empty && channelData.content.value?.childs?.[0]) {
+    // 没有文章时,并且有子分类时,尝试读取第一个子分类的文章
+    const res = await $fetch('/api/article/byChannel', {
+      method: 'GET',
+      query: { 
+        channelId: channelData.content.value.childs[0].id,
+        page: route.query.page || 1,
+        pageSize: 10,
+      }
+    });
+    if (!res.status)
+      throw new Error(res.message);
+    return res.data;
+  }
+  return res.data;
+});
 
+const channelName = computed(() => channelData.content.value?.name || '');
 
+watch(() => route.query.page, async (newVal, oldVal) => {
+  if (newVal !== oldVal) {
+    articlesData.loadData(undefined, true);
+  }
+})
 
 </script>
 

+ 169 - 0
src/pages/channel/laws.vue

@@ -0,0 +1,169 @@
+<template>
+  <!-- 分类 -->
+  <div class="main-background">
+    <!-- SEO -->
+    <Head>
+      <Title>厦门市文化遗产保护中心 - {{ channelName }}</Title>
+      <Meta name="description" content="" />
+      <Meta name="keywords" content="" />
+    </Head>
+    
+    <!-- 轮播图 -->
+    <img class="main-header-image" src="http://xmswhycbhzx.cn/uploads/20251013/6af88ead615998ad6c789327b26f7bbe.jpg">
+
+    <!-- 主要内容 -->
+    <div class="main-content">
+      <div class="container">
+        <div class="row">
+          <!-- 右侧内容 -->
+          <div class="col-12">
+            <div class="content">
+              <div class="section-title center large">
+                <h2 class="icon">政策法规</h2>
+              </div>
+              
+              <!-- 搜索区域 -->
+              <div class="d-flex justify-content-center align-items-center gap-3 mb-4">
+                <button 
+                  v-for="value in channelData.content.value?.childs"
+                  :key="value.id"
+                  class="bordered" 
+                  :class="{ 'active': selectedCategory === value.id }"
+                  @click="selectCategory(value.id)"
+                >
+                  {{ value.name }}
+                </button>
+                <div class="bordered-input">
+                  <input 
+                    type="text" 
+                    id="search-input" 
+                    placeholder="输入关键字搜索政策法规" 
+                    v-model="searchKeyword"
+                  >
+                </div>
+                <button 
+                  class="bordered active" 
+                  @click="performSearch"
+                >
+                  搜索
+                </button>
+              </div>
+              
+              <!-- 文章列表 -->
+              <SimplePageContentLoader :loader="articlesData">
+                <div class="news-list">
+                  <div 
+                    v-for="(item, key) in articlesData.content.value?.items" 
+                    :key="key" 
+                    class="news-item dark"
+                  >
+                    <router-link 
+                      :to="'/page/' + item.id"
+                      class="title"
+                    >
+                      {{ item.title }}
+                    </router-link>
+                    <span class="date">
+                      {{ DateUtils.formatDate(new Date(item.publishtime * 1000), 'yyyy-MM-dd') }}
+                    </span>
+                  </div>
+                  <div v-if="articlesData.content.value?.empty" class="no-news text-center">
+                    {{ searchKeyword ? '没有找到与"' + searchKeyword + '"相关的数据' : '暂无相关数据' }}
+                  </div>
+                </div>
+                
+                <!-- 分页 -->
+                <SimplePagination 
+                  v-if="articlesData.content.value"
+                  :currPage="articlesData.content.value.pageIndex"
+                  :allPage="articlesData.content.value.allPage"
+                  :maxCount="10"
+                  @change="updateQuery"
+                />
+              </SimplePageContentLoader>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useRouter, useRoute } from 'vue-router';
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { DateUtils } from '@imengyu/imengyu-utils';
+import type { IChannel } from '~~/server/api/channel/[id]';
+import SimplePagination from '~/components/content/SimplePagination.vue';
+
+const route = useRoute();
+const router = useRouter();
+const channelId = parseInt(route.query.id as string);
+const searchKeyword = ref(route.query.keyword as string || '');
+
+// 分类选择方法
+const selectCategory = (category: number) => {
+  selectedCategory.value = category;
+  updateQuery();
+};
+const performSearch = () => {
+  updateQuery();
+};
+// 更新查询参数
+const updateQuery = () => {
+  router.push({
+    query: {
+      ...route.query,
+      category: selectedCategory.value,
+      keyword: searchKeyword.value,
+      page: 1 // 切换分类或搜索时回到第一页
+    }
+  });
+};
+
+const channelData = await useSSrSimpleDataLoader('channel' + channelId, async () => {
+  const res = await $fetch(`/api/channel/${channelId}`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data as IChannel & {
+    childs: IChannel[];
+    parents: IChannel[];
+  };
+});
+
+const selectedCategory = ref(
+  route.query.category ?
+  parseInt(route.query.category as string) :
+  (channelData.content.value?.childs[0]?.id || channelId)
+);
+const articlesData = await useSSrSimpleDataLoader('articles' + channelId, async () => {
+  const res = await $fetch('/api/article/byChannel', {
+    method: 'GET',
+    query: { 
+      channelId: selectedCategory.value,
+      page: route.query.page || 1,
+      pageSize: 10,
+    }
+  });
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+
+const channelName = computed(() => channelData.content.value?.name || '');
+
+watch(() => route.query.page, async (newVal, oldVal) => {
+  if (newVal !== oldVal) {
+    articlesData.loadData(undefined, true);
+  }
+})
+
+</script>
+
+<style lang="scss">
+@use "sass:list";
+@use "sass:math";
+</style>
+

+ 252 - 3
src/pages/index.vue

@@ -10,7 +10,7 @@
     <!-- 轮播 -->
     <Carousel v-bind="carouselConfig" class="main-header-box small carousel-light">
       <Slide 
-        v-for="(item, key) in newsData.content.value"
+        v-for="(item, key) in carouselData.content.value"
         :key="key"
         class="main-header-box small"
       >
@@ -22,6 +22,191 @@
       </template>
     </Carousel>
 
+    <!-- 主要内容 -->
+    <div class="main-content">
+      <div class="container">
+        <!-- 通知公告和热点新闻 -->
+        <div class="row">
+
+          <div class="col col-sm-12 col-md-6 col-lg-6 notices-list">
+            <div class="section-title">
+              <div class="d-flex flex-row align-items-center">
+                <h2 id="notices-button" @click="currentTab = 'notices'" :class="{'active': currentTab === 'notices'}" class="button icon">通知公告</h2>
+                <h2 id="hot-news-button" @click="currentTab = 'hot'" :class="{'active': currentTab === 'hot'}" class="button icon">热点新闻</h2>
+              </div>
+              <router-link :to="currentTab === 'notices' ? 
+                `/channel/${notices.content.value?.channel_id}` : 
+                `/channel/${hot.content.value?.channel_id}`" 
+                class="section-more">
+                查看更多 <Icon name="material-symbols-light:chevron-right" />
+              </router-link>
+            </div>
+
+            <div v-if="currentTab === 'notices'" id="notices-list" class="notices-content">
+              <SimplePageContentLoader :loader="notices" :showEmpty="!notices.content.value?.items?.length">
+                <div v-for="notice in notices.content.value?.items" :key="notice.id" class="notice-item">
+                  <div>
+                    <router-link :to="'/page/' + notice.id" class="notice-title">{{ notice.title }}</router-link>
+                    <p>{{ notice.description }}</p>
+                  </div>
+                  <span class="notice-publishtime">{{ DataDateUtils.formatDate(new Date(notice.publishtime * 1000), 'yyyy-MM-dd') }}</span>
+                </div>
+              </SimplePageContentLoader>
+            </div>
+            <div v-if="currentTab === 'hot'" id="hot-news-list" class="news-content">
+              <SimplePageContentLoader :loader="hot" :showEmpty="!hot.content.value?.items?.length">
+                <div v-for="item in hot.content.value?.items" :key="item.id" class="news-item">
+                  <router-link :to="'/page/' + item.id" class="title">{{ item.title }}</router-link>
+                  <span class="publishtime">{{ DataDateUtils.formatDate(new Date(item.publishtime * 1000), 'yyyy-MM-dd') }}</span>
+                </div>
+              </SimplePageContentLoader>
+            </div>
+
+          </div>
+
+          <div class="col col-sm-12 col-md-6 col-lg-6">
+            <Carousel v-bind="carouselConfig">
+              <Slide 
+                v-for="(item, key) in recommendArticles.content.value"
+                :key="key"
+                class="featured-image bg-secondary"
+                :style="{ 'background-image': `url(${item.image})`, 'background-size': 'cover' }"
+              >
+                <router-link :to="'/page/' + item.id">
+                  <img :src="item.image" :alt="item.desc || item.title" style="width: 100%; height: 100%; object-fit: contain;">
+                  <div class="caption">
+                    <h3>{{ item.title }}</h3>
+                    <p>{{ item.desc || '' }}</p>
+                  </div>
+                </router-link>
+              </Slide>
+              <template #addons>
+                <Navigation />
+                <Pagination />
+              </template>
+            </Carousel>
+            <div class="d-flex flex-row justify-content-between">
+              <a href="http://cpc.people.com.cn/GB/67481/448544/index.html" class="w-50">
+                <img src="https://wlj.xm.gov.cn/ztzl/202211/W020221213510920336898.jpg" class="mt-3" style="width: 100%; height:130px;object-fit:cover;" /> 
+              </a>
+              <a href="https://www.12371.cn/special/xxzd/" class="w-50">
+                <img src="@/assets/images/xuexi.png" class="mt-3" style="width: 100%; height:130px;object-fit:cover;" /> 
+              </a>
+            </div>
+          </div>
+        </div>
+
+        <!-- 工作动态和党建工作 -->
+        <div class="row mt-lg-3 mt-md-2 mt-sm-2">
+
+          <div class="col col-sm-12 col-md-5 col-lg-5">
+            <div class="main-box fill">
+              <div class="section-title">
+                <h2><i class="fa fa-flag"></i> 党建工作</h2>
+                <router-link :to="`/channel/${partyBuilding.content.value?.channel_id}`" class="section-more">查看更多 <Icon name="material-symbols-light:chevron-right" /></router-link>
+              </div>
+              <div class="news-content">
+                <SimplePageContentLoader :loader="partyBuilding" :showEmpty="!partyBuilding.content.value?.items?.length">
+                  <div v-for="item in partyBuilding.content.value?.items" :key="item.id" class="news-item">
+                    <router-link :to="'/page/' + item.id" class="title">{{ item.title }}</router-link>
+                    <span class="publishtime">{{ DataDateUtils.formatDate(new Date(item.publishtime * 1000), 'yyyy-MM-dd') }}</span>
+                  </div>
+                </SimplePageContentLoader>
+              </div>
+            </div>
+          </div>
+
+          <div class="col col-sm-12 col-md-5 col-lg-5">
+            <div class="main-box fill">
+              <div class="section-title">
+                <h2><i class="fa fa-refresh"></i> 工作动态</h2>
+                <router-link :to="`/channel/${workUppublishtimes.content.value?.channel_id}`" class="section-more">查看更多 <Icon name="material-symbols-light:chevron-right" /></router-link>
+              </div>
+              <div class="news-content">
+                <SimplePageContentLoader :loader="workUppublishtimes" :showEmpty="!workUppublishtimes.content.value?.items?.length">
+                  <div v-for="uppublishtime in workUppublishtimes.content.value?.items" :key="uppublishtime.id" class="news-item">
+                    <router-link :to="'/page/' + uppublishtime.id" class="title">{{ uppublishtime.title }}</router-link>
+                    <span class="publishtime">{{ DataDateUtils.formatDate(new Date(uppublishtime.publishtime * 1000), 'yyyy-MM-dd') }}</span>
+                  </div>
+                </SimplePageContentLoader>
+              </div>
+            </div>
+          </div>
+
+          <div class="col col-sm-12 col-md-2 col-lg-2">
+            
+            <Carousel v-bind="carouselConfig">
+              <Slide 
+                v-for="(item, key) in carousel2Data.content.value"
+                :key="key"
+                class="featured-image bg-secondary"
+                :style="{ 'background-image': `url(${item.image})`, 'background-size': 'cover' }"
+              >
+                <img 
+                  :src="item.image" style="width: 100%; height: 100%; object-fit: contain;"
+                  @click="handleImageClick(item.image)"
+                >
+              </Slide>
+              <template #addons>
+                <Navigation />
+                <Pagination />
+              </template>
+            </Carousel>
+          </div>
+        </div>
+
+        <hr class="main-hr" />
+
+        <!-- 精彩推荐 -->
+        <div class="featured-section main-box">
+          <div class="section-title">
+            <h2 class="icon">精彩推荐</h2>
+            <!-- <a href="#" class="section-more">查看更多 <Icon name="material-symbols-light:chevron-right" /></a> -->
+          </div>
+          <div class="featured-grid">
+            <SimplePageContentLoader :loader="featured" :showEmpty="!featured.content.value?.items?.length">
+              <div v-for="item in featured.content.value?.items" :key="item.id" class="featured-card">
+                <router-link :to="'/page/' + item.id">
+                  <img :src="item.image" :alt="item.desc || item.title">
+                  <p>{{ item.title }}</p>
+                </router-link>
+              </div>
+            </SimplePageContentLoader>
+          </div>
+        </div>
+
+        <!-- 公共服务 -->
+        <div class="services-section">
+          <img class="title" src="@/assets/images/box-service.png" alt="公共服务">
+          <div class="services-grid">
+
+            <div class="service-card">
+              <img class="icon" src="@/assets/images/icon-explore.png">
+              <h3>志愿者招募</h3>
+              <a href="#" class="service-link">
+                <i class="fa fa-solid fa-chevron-right"></i>
+              </a>
+            </div>
+
+            <div class="service-card">
+              <img class="icon" src="@/assets/images/icon-join.png">
+              <h3>活动报名</h3>
+              <a href="#" class="service-link">
+                <i class="fa fa-solid fa-chevron-right"></i>
+              </a>
+            </div>
+
+            <div class="service-card">
+              <img class="icon" src="@/assets/images/icon-contract.png">
+              <h3>联系咨询通道</h3>
+              <a href="#" class="service-link">
+                <i class="fa fa-solid fa-chevron-right"></i>
+              </a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 
@@ -32,15 +217,79 @@ import { useRouter } from 'vue-router';
 import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
 import { DataDateUtils } from '@imengyu/js-request-transform';
 import { ScrollRect } from '@imengyu/vue-scroll-rect';
+import CarouselItem1 from '@/assets/images/board1/1.jpg';
+import CarouselItem2 from '@/assets/images/board1/2.jpg';
+import CarouselItem3 from '@/assets/images/board1/3.jpg';
+import CarouselItem4 from '@/assets/images/board1/4.jpg';
+import CarouselItem5 from '@/assets/images/board1/5.jpg';
 
 const carouselConfig = {
   itemsToShow: 1,
   wrapAround: true,
   autoPlay: 5000,
 }
-const newsData = await useSSrSimpleDataLoader('news', async () => {
-  throw new Error('newsData is not implemented');
+const carouselData = await useSSrSimpleDataLoader('carousel', async () => {
+  const res = await $fetch(`/api/carousel`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+const carousel2Data = await useSSrSimpleDataLoader('carousel2', async () => {
+  return [
+    { image: CarouselItem1, title: 'carousel item 1' },
+    { image: CarouselItem2, title: 'carousel item 2' },
+    { image: CarouselItem3, title: 'carousel item 3' },
+    { image: CarouselItem4, title: 'carousel item 4' },
+    { image: CarouselItem5, title: 'carousel item 5' },
+  ];
+});
+
+const currentTab = ref('notices');
+
+const recommendArticles = await useSSrSimpleDataLoader('recommendArticles', async () => {
+  const res = await $fetch(`/api/article/recommend`, { query: { page: 1, pageSize: 6 } });
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+const notices = await useSSrSimpleDataLoader('notices', async () => {
+  const res = await $fetch(`/api/article/byChannelName?channelName=新闻公告`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
 });
+// 工作动态数据
+const workUppublishtimes = await useSSrSimpleDataLoader('workUppublishtimes', async () => {
+  const res = await $fetch(`/api/article/byChannelName?channelName=工作动态`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+// 党建工作数据
+const partyBuilding = await useSSrSimpleDataLoader('partyBuilding', async () => {
+  const res = await $fetch(`/api/article/byChannelName?channelName=党建工作`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+// 热点新闻数据
+const hot = await useSSrSimpleDataLoader('hot', async () => {
+  const res = await $fetch(`/api/article/byChannelName?channelName=热门新闻`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+// 精彩推荐数据
+const featured = await useSSrSimpleDataLoader('featured', async () => {
+  const res = await $fetch(`/api/article/byChannelName?channelName=热门新闻`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+
+function handleImageClick(image: string) {
+  window.open(image, '_blank');
+}
 </script>
 
 <style lang="scss">

+ 121 - 0
src/pages/page/[id].vue

@@ -0,0 +1,121 @@
+<template>
+  <!-- 分类 -->
+  <div class="main-background">
+    <!-- SEO -->
+    <Head>
+      <Title>厦门市文化遗产保护中心 - {{ articlesData.content.value?.title }}</Title>
+      <Meta name="description" content="" />
+      <Meta name="keywords" content="" />
+    </Head>
+    <!-- 轮播 -->
+    <Carousel v-bind="carouselConfig" class="main-header-box small carousel-light">
+      <Slide 
+        v-for="(item, key) in carouselData.content.value"
+        :key="key"
+        class="main-header-box small"
+      >
+        <img v-if="item.image" :src="item.image" />
+      </Slide>
+      <template #addons>
+        <Navigation />
+        <Pagination />
+      </template>
+    </Carousel>
+
+    <!-- 主要内容 -->
+    <div class="main-content">
+      <div class="container">
+        <div class="row">
+          <!-- 左侧导航 -->
+          <div class="col-12 col-sm-12 col-md-4 col-lg-3">
+            <div class="sidebar">
+              <div class="title">
+                <h2>{{ articlesData.content.value?.channel?.name }}</h2>
+              </div>
+              <ul class="sidebar-menu">
+                <li @click="router.back()">
+                  <div>
+                    <Icon name="material-symbols:undo" />
+                    返回上一级
+                  </div>
+                </li>
+              </ul>
+            </div>
+          </div>
+          
+          <!-- 右侧内容 -->
+          <div class="col-12 col-sm-12 col-md-8 col-lg-9">
+            <div class="content">
+              <div class="section-title">
+                <h2 class="icon">{{ articlesData.content.value?.channel?.name }}</h2>
+                
+                <nav aria-label="breadcrumb">
+                  <ol class="breadcrumb">
+                    <li class="breadcrumb-item"><a href="/">首页</a></li>
+                    <li class="breadcrumb-item active" aria-current="page">{{ articlesData.content.value?.channel?.name }}</li>
+                  </ol>
+                </nav>
+              </div>
+              
+              <SimplePageContentLoader :loader="articlesData">
+                <div class="news-detail">
+                  <h1>{{ articlesData.content.value?.title }}</h1>
+                  <div class="times">
+                    <p class="date">时间:{{ DateUtils.formatDate(new Date(articlesData.content.value?.publishtime || 0 * 1000), 'yyyy-MM-dd') }}</p>
+                    <p class="views">浏览量: {{ articlesData.content.value?.views }} </p>
+                  </div>
+                  <div class="content-text" v-html="articlesData.content.value?.content">
+                  </div>
+                </div>
+              </SimplePageContentLoader>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { DateUtils } from '@imengyu/imengyu-utils';
+import SimplePageContentLoader from '~/components/content/SimplePageContentLoader.vue';
+
+const carouselConfig = {
+  itemsToShow: 1,
+  wrapAround: true,
+  autoPlay: 5000,
+}
+
+const route = useRoute();
+const router = useRouter();
+const articleId = parseInt(route.params.id as string);
+
+const carouselData = await useSSrSimpleDataLoader('carousel', async () => {
+  const res = await $fetch(`/api/carousel`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+const articlesData = await useSSrSimpleDataLoader('articles' + articleId, async () => {
+  const res = await $fetch(`/api/article/${articleId}`, {
+    method: 'GET',
+  });
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+
+watch(() => route.query.id, async (newVal, oldVal) => {
+  if (newVal !== oldVal) {
+    articlesData.loadData(undefined, true);
+  }
+});
+</script>
+
+<style lang="scss">
+@use "sass:list";
+@use "sass:math";
+</style>
+

+ 89 - 0
src/pages/search.vue

@@ -0,0 +1,89 @@
+<template>
+  <!-- 分类 -->
+  <div class="main-background">
+    <!-- SEO -->
+    <Head>
+      <Title>厦门市文化遗产保护中心 - 搜索</Title>
+      <Meta name="description" content="" />
+      <Meta name="keywords" content="" />
+    </Head>
+
+    <!-- 主要内容 -->
+    <div class="main-content">
+      <div class="container">
+        <div class="row justify-content-center">
+          
+          <!-- 右侧内容 -->
+          <div class="col-12 col-sm-12 col-md-8 col-lg-9">
+            <div class="content">
+              <div class="section-title">
+                <h2 class="icon">搜索结果:{{ keyword }}</h2>
+                
+                <nav aria-label="breadcrumb">
+                  <ol class="breadcrumb">
+                    <li class="breadcrumb-item"><router-link to="/">首页</router-link></li>
+                    <li class="breadcrumb-item active" aria-current="page">搜索</li>
+                  </ol>
+                </nav>
+              </div>
+              
+              <!-- 文章列表 -->
+              <SimplePageContentLoader :loader="articlesData">
+                <div class="news-list">
+                  <div v-for="(item, key) in articlesData.content.value?.items" :key="key" class="news-item">
+                    <router-link :to="'/page/' + item.id" class="title">{{ item.title }}</router-link>
+                    <span class="date">{{ DateUtils.formatDate(new Date(item.publishtime * 1000), 'yyyy-MM-dd') }}</span>
+                  </div>
+                  <div v-if="!articlesData.content.value || articlesData.content.value.empty" class="no-news">暂无相关文章</div>
+                </div>
+                <!-- 分页 -->
+                <SimplePagination
+                  v-if="articlesData.content.value" 
+                  :currPage="articlesData.content.value.pageIndex"
+                  :allPage="articlesData.content.value.allPage"
+                  :maxCount="10"
+                />
+              </SimplePageContentLoader>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { DateUtils } from '@imengyu/imengyu-utils';
+import SimplePagination from '~/components/content/SimplePagination.vue';
+
+const route = useRoute();
+const keyword = route.query.keyword as string;
+
+const articlesData = await useSSrSimpleDataLoader('articles' + keyword, async () => {
+  const res = await $fetch('/api/article/search', {
+    method: 'GET',
+    query: { 
+      search: keyword,
+      page: route.query.page || 1,
+      pageSize: 10,
+    }
+  });
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+
+watch(route, async (newVal, oldVal) => {
+  if (newVal !== oldVal) {
+    articlesData.loadData(undefined, true);
+  }
+});
+</script>
+
+<style lang="scss">
+@use "sass:list";
+@use "sass:math";
+</style>
+

+ 0 - 74
src/stores/auth.ts

@@ -1,74 +0,0 @@
-import UserApi, { LoginResult, UserInfo } from "@/api/auth/UserApi";
-import { defineStore } from "pinia"
-
-const STORAGE_KEY = 'authInfo';
-
-export const useAuthStore = defineStore('auth', {
-  state: () => ({
-    token: '',
-    expireAt: 0,
-    userId: 0,
-    userInfo: null as null|UserInfo,
-  }),
-  actions: {
-    async loadLoginState() {
-      try {
-        const res = localStorage.getItem(STORAGE_KEY);
-        if (!res)
-          throw 'no storage';
-        const authInfo = JSON.parse(res);
-        this.token = authInfo.token;
-        this.userId = authInfo.userId;
-        this.expireAt = authInfo.expireAt;
-
-        // 检查token是否过期,如果快要过期,则刷新token
-        if (Date.now() > this.expireAt + 1000 * 3600 * 5) {
-          const refreshResult = await UserApi.refresh();
-          this.loginResultHandle(refreshResult);
-          this.userInfo = refreshResult.userInfo;
-        } else {
-          this.userInfo = await UserApi.getUserInfo(this.userId);
-        }
-      } catch (error) {
-        this.token = '';
-        this.userId = 0;
-        this.userInfo = null;
-
-        console.log('loadLoginState', error);
-      }
-    },
-    async loginAdmin(account: string, password: string) {
-      const loginResult = await UserApi.loginAdmin({
-        account,
-        password,
-      })
-      this.loginResultHandle(loginResult);
-    },
-    async loginResultHandle(loginResult: LoginResult) {
-      this.token = loginResult.userInfo.token;
-      this.userId = loginResult.userInfo.id;
-      this.userInfo = loginResult.userInfo;
-      this.expireAt = loginResult.userInfo.expiresIn + Date.now();
-
-      localStorage.setItem(STORAGE_KEY, 
-        JSON.stringify({ 
-          token: this.token, 
-          userId: this.userId ,
-          expireAt: this.expireAt,
-        }) 
-      );
-    },
-    async logout() {
-      this.token = '';
-      this.userId = 0;
-      this.userInfo = null;
-
-      localStorage.removeItem(STORAGE_KEY);
-    }
-  },
-  getters: {
-    isLogged(state) {
-      return state.token != '' && state.userId != 0
-    },
-  },
-})