瀏覽代碼

初始版本

快乐的梦鱼 3 周之前
當前提交
ea8032d023
共有 83 個文件被更改,包括 11511 次插入 和 0 次删除
  1. 39 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 81 0
      README.md
  4. 1 0
      env.d.ts
  5. 13 0
      index.html
  6. 5743 0
      package-lock.json
  7. 51 0
      package.json
  8. 二進制
      public/favicon.ico
  9. 71 0
      refer/culture-resources.html
  10. 54 0
      refer/culture-tourism.html
  11. 45 0
      refer/digital-archives.html
  12. 68 0
      refer/heritage-experience.html
  13. 108 0
      refer/home.html
  14. 64 0
      refer/protection-overview.html
  15. 52 0
      refer/public-communication.html
  16. 5 0
      scripts/.gitignore
  17. 395 0
      scripts/UpdateScript/app.mjs
  18. 273 0
      scripts/UpdateScript/auth.mjs
  19. 22 0
      scripts/UpdateScript/deprecate.html
  20. 63 0
      scripts/UpdateScript/index.mjs
  21. 76 0
      scripts/UpdateScript/postConfig.mjs
  22. 424 0
      scripts/UpdateScript/postUpdate.mjs
  23. 141 0
      scripts/UpdateScript/utils.mjs
  24. 7 0
      src/App.vue
  25. 80 0
      src/assets/base.css
  26. 3 0
      src/assets/main.css
  27. 159 0
      src/assets/solar-icons.json
  28. 42 0
      src/components/DashboardLayout.vue
  29. 53 0
      src/components/PageFooter.vue
  30. 88 0
      src/components/PageHeader.vue
  31. 33 0
      src/components/PanelCard.vue
  32. 25 0
      src/components/ProgressBar.vue
  33. 25 0
      src/main.ts
  34. 43 0
      src/plugins/echarts.ts
  35. 45 0
      src/router/index.ts
  36. 44 0
      src/views/culture-resources/CultureResourcesView.vue
  37. 38 0
      src/views/culture-resources/components/CultureGallerySection.vue
  38. 110 0
      src/views/culture-resources/components/HeritageList.vue
  39. 33 0
      src/views/culture-resources/components/RecommendationCard.vue
  40. 25 0
      src/views/culture-resources/components/RecommendationSection.vue
  41. 72 0
      src/views/culture-resources/components/ResourceOverviewPanel.vue
  42. 84 0
      src/views/culture-resources/components/ResourceSystemPanel.vue
  43. 114 0
      src/views/culture-resources/components/TalentList.vue
  44. 368 0
      src/views/culture-resources/data.ts
  45. 44 0
      src/views/culture-tourism/CultureTourismView.vue
  46. 62 0
      src/views/culture-tourism/components/TourismModulePanel.vue
  47. 78 0
      src/views/culture-tourism/data.ts
  48. 44 0
      src/views/digital-archives/DigitalArchivesView.vue
  49. 51 0
      src/views/digital-archives/components/ArchiveDirectoryPanel.vue
  50. 35 0
      src/views/digital-archives/components/ArchiveFilterPanel.vue
  51. 103 0
      src/views/digital-archives/data.ts
  52. 60 0
      src/views/heritage-experience/HeritageExperienceView.vue
  53. 55 0
      src/views/heritage-experience/components/ActivityFeedPanel.vue
  54. 49 0
      src/views/heritage-experience/components/ActivityOverviewPanel.vue
  55. 27 0
      src/views/heritage-experience/components/CampusEducationPanel.vue
  56. 31 0
      src/views/heritage-experience/components/ProfessionalEducationPanel.vue
  57. 27 0
      src/views/heritage-experience/components/StudyRoutesPanel.vue
  58. 39 0
      src/views/heritage-experience/components/TeachersPanel.vue
  59. 170 0
      src/views/heritage-experience/data.ts
  60. 43 0
      src/views/home/HomeView.vue
  61. 134 0
      src/views/home/components/CultureMapPanel.vue
  62. 70 0
      src/views/home/components/DigitalArchivePanel.vue
  63. 35 0
      src/views/home/components/InheritorTiersPanel.vue
  64. 41 0
      src/views/home/components/ProtectionStatusPanel.vue
  65. 28 0
      src/views/home/components/RecentUpdatesPanel.vue
  66. 30 0
      src/views/home/components/ResourceCategoryPanel.vue
  67. 49 0
      src/views/home/components/StatCard.vue
  68. 33 0
      src/views/home/components/WeeklyPatrolPlanPanel.vue
  69. 30 0
      src/views/home/components/WorkMechanismPanel.vue
  70. 381 0
      src/views/home/data.ts
  71. 20 0
      src/views/protection-overview/ProtectionOverviewView.vue
  72. 51 0
      src/views/protection-overview/components/OverviewCard.vue
  73. 78 0
      src/views/protection-overview/data.ts
  74. 28 0
      src/views/public-communication/PublicCommunicationView.vue
  75. 44 0
      src/views/public-communication/components/CommunicationPanel.vue
  76. 27 0
      src/views/public-communication/components/ConstructionPanel.vue
  77. 35 0
      src/views/public-communication/components/FeedbackPanel.vue
  78. 35 0
      src/views/public-communication/components/VolunteerPanel.vue
  79. 84 0
      src/views/public-communication/data.ts
  80. 18 0
      tsconfig.app.json
  81. 11 0
      tsconfig.json
  82. 27 0
      tsconfig.node.json
  83. 21 0
      vite.config.ts

+ 39 - 0
.gitignore

@@ -0,0 +1,39 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo
+
+.eslintcache
+
+# Cypress
+/cypress/videos/
+/cypress/screenshots/
+
+# Vitest
+__screenshots__/
+
+# Vite
+*.timestamp-*-*.mjs

+ 3 - 0
.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar"]
+}

+ 81 - 0
README.md

@@ -0,0 +1,81 @@
+# minnan-zhangzhou-admin
+
+[闽南文化(漳州)生态保护区数字驾驶舱](https://zhangzhou.wenlvti.net/admin)管理端前端项目。
+
+## 技术栈
+
+### 前端核心技术
+
+- **框架**: Vue 3(Composition API + `<script setup>`)
+- **核心库**: Vue Router、Pinia
+- **类型系统**: TypeScript
+- **样式方案**: Tailwind CSS v4(`@tailwindcss/vite` 插件)
+- **构建工具**: Vite
+- **图表**: ECharts(`vue-echarts` 封装,按需注册)
+- **图标**: `@iconify/vue` + Solar 图标集(本地按需打包,见 `src/assets/solar-icons.json`)
+
+## 项目目录组织
+
+```
+src/
+  assets/           # 全局样式与静态资源
+    base.css        # 主题变量(闽南红砖/金漆配色)与动画
+    main.css        # 样式入口(引入 Tailwind 与 base.css)
+    solar-icons.json# 按需提取的 Solar 图标集合
+  components/       # 全局复用组件
+    DashboardLayout.vue  # 页面骨架(背景 + 顶栏 + 内容区 + 页脚)
+    PageHeader.vue       # 顶部导航(含路由高亮)
+    PageFooter.vue       # 页脚(含实时时钟)
+    PanelCard.vue        # 通用卡片(标题栏 + 插槽)
+    ProgressBar.vue      # 进度条
+  plugins/
+    echarts.ts      # ECharts 按需注册与 VChart 组件导出
+  router/
+    index.ts        # 路由配置
+  stores/           # Pinia 状态管理
+  views/            # 页面(每个页面为独立目录)
+    home/                       # 首页
+    protection-overview/        # 保护区概况
+    culture-resources/          # 文化资源
+    heritage-experience/        # 传承体验
+    culture-tourism/            # 文旅融合
+    public-communication/       # 公众传播
+    digital-archives/           # 数字档案
+      ├── components/           # 页面级组件
+      ├── data.ts               # 静态数据(含后端对接注释)
+      └── XxxView.vue           # 页面主视图
+```
+
+> 组件划分约定:全局复用的组件放在 `src/components/`;仅当前页面使用的组件放在页面同级的 `components/` 目录下。
+> 静态数据集中在各页面的 `data.ts`,以 TypeScript 接口约束结构并标注注释,后续对接后端时直接替换即可。
+
+## 开发与构建
+
+### 项目准备
+
+```bash
+npm install
+```
+
+### 开发命令
+
+```bash
+# 启动开发服务器(热更新)
+npm run dev
+```
+
+### 构建命令
+
+```bash
+# 类型检查 + 构建生产包(含类型检查)
+npm run build
+
+# 仅构建(跳过类型检查)
+npm run build-only
+
+# 仅类型检查
+npm run type-check
+
+# 预览构建结果
+npm run preview
+```

+ 1 - 0
env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>漳州文化生态保护区数字驾驶舱</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

文件差異過大導致無法顯示
+ 5743 - 0
package-lock.json


+ 51 - 0
package.json

@@ -0,0 +1,51 @@
+{
+  "name": "minnan-zhangzhou-admin",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "run-p type-check \"build-only {@}\" --",
+    "preview": "vite preview",
+    "build-only": "vite build",
+    "updater": "node scripts/UpdateScript/index.mjs",
+    "type-check": "vue-tsc --build"
+  },
+  "dependencies": {
+    "@iconify-json/solar": "^1.2.10",
+    "@iconify/vue": "^5.0.1",
+    "@inquirer/prompts": "^8.7.2",
+    "@tailwindcss/vite": "^4.3.3",
+    "ali-oss": "^6.23.0",
+    "archiver": "^8.0.0",
+    "axios": "^1.20.0",
+    "cli-progress": "^3.12.0",
+    "cli-table3": "^0.6.5",
+    "commander": "^15.0.0",
+    "consola": "^3.4.2",
+    "echarts": "^6.1.0",
+    "form-data": "^4.0.6",
+    "json5": "^2.2.3",
+    "md5": "^2.3.0",
+    "minimatch": "^10.2.6",
+    "pinia": "^4.0.2",
+    "tailwindcss": "^4.3.3",
+    "vue": "^3.5.40",
+    "vue-echarts": "^8.3.0",
+    "vue-router": "^5.2.0"
+  },
+  "devDependencies": {
+    "@tsconfig/node24": "^24.0.4",
+    "@types/node": "^24.13.3",
+    "@vitejs/plugin-vue": "^6.0.8",
+    "@vue/tsconfig": "^0.9.1",
+    "npm-run-all2": "^9.0.2",
+    "typescript": "~6.0.0",
+    "vite": "^8.1.5",
+    "vite-plugin-vue-devtools": "^8.1.5",
+    "vue-tsc": "^3.3.7"
+  },
+  "engines": {
+    "node": "^22.18.0 || >=24.12.0"
+  }
+}

二進制
public/favicon.ico


文件差異過大導致無法顯示
+ 71 - 0
refer/culture-resources.html


文件差異過大導致無法顯示
+ 54 - 0
refer/culture-tourism.html


文件差異過大導致無法顯示
+ 45 - 0
refer/digital-archives.html


文件差異過大導致無法顯示
+ 68 - 0
refer/heritage-experience.html


文件差異過大導致無法顯示
+ 108 - 0
refer/home.html


文件差異過大導致無法顯示
+ 64 - 0
refer/protection-overview.html


文件差異過大導致無法顯示
+ 52 - 0
refer/public-communication.html


+ 5 - 0
scripts/.gitignore

@@ -0,0 +1,5 @@
+node_modules
+_config.json
+_localConfig.json
+_token.json
+upload.zip

+ 395 - 0
scripts/UpdateScript/app.mjs

@@ -0,0 +1,395 @@
+/**
+ * 更新发布工具
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { program } from 'commander';
+import { input, confirm, select } from '@inquirer/prompts';
+import { postAppUpdate, postWebUpdate } from './postUpdate.mjs';
+import Table from 'cli-table3';
+import { config } from './postConfig.mjs';
+import {
+  axiosInstance,
+  initAuth,
+  login,
+  logout,
+  checkLogged,
+} from './auth.mjs';
+import consola from 'consola';
+import { logError } from './utils.mjs';
+
+// 版本相关
+//========================================
+
+export async function viewVersion(type) {
+  if (type === 'all' || !type) {
+    const data = await axiosInstance.get('/version/list');
+    const table = new Table({
+      head: ['ID', '版本'], 
+      colWidths: [10, 20 ]
+    });
+
+    data.data.forEach((d) => {
+      table.push([ d.id, d.version ]);
+    })
+    console.log(table.toString());
+  } else {
+    let data = null;
+    try {
+      if (Number.isNaN(Number(type)))
+        data = await axiosInstance.get('/version/get-by-name?name=' + type);
+      else
+        data = await axiosInstance.get('/version/' + type);
+    }
+    catch (e) {
+      logError('Failed to load version info', e);
+      return;
+    }
+    const table = new Table({
+      head: ['key', 'data'], 
+      colWidths: [30, 60]
+    });
+    table.push(
+      [ 'ID', data.data.id ],
+      [ '状态', stateConstant[data.data.status] ],
+      [ '版本号', data.data.version ],
+      [ '创建时间', new Date(data.data.createAt).toString() ],
+      [ '设置', data.data.config ],
+      [ '激活的Web更新ID', data.data.webUpdateId ],
+      [ '激活的App更新ID', data.data.appUpdateId ],
+      [ '激活的下一个App更新ID', data.data.appUpdateNextId ],
+    );
+    console.log(table.toString());
+  }
+}
+export async function getVersion(action, type) {
+  switch(action) {
+    case 'view':
+      await viewVersion(type);
+      break;
+    case 'new': {
+      const version = await input({ message: 'Enter version name, like (1.0.0)' });
+      try {
+        await axiosInstance.post('/version', {
+          version: version,
+          status: 1,
+          config: "{}",
+        });
+        consola.success('Add version success');
+      } catch (e) {
+        logError('Failed to add version', e);
+      }
+      break;
+    }
+    case 'delete': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      if (!await confirm({ message: `确定删除版本 ${versionId}?`, default: false }))
+        return;
+      if (!await confirm({ message: '确认删除版本?此操作会删除所属版本的所有更新项目、存储等,无法恢复,是否确定删除?', default: false }))
+        return;
+      try {
+        await axiosInstance.delete('/version/' + versionId);
+        consola.success('删除版本成功');
+      } catch (e) {
+        logError('删除版本失败', e);
+      }
+      break;
+    }
+    case 'set-state': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      const state = await select({
+        message: '设置状态',
+        choices: [
+          {
+            name: 'NotEnable',
+            value: 0,
+          },
+          {
+            name: 'Normal',
+            value: 1,
+          },
+          {
+            name: 'Deprecated',
+            value: 2,
+          },
+        ],
+      });
+
+      try {
+        await axiosInstance.put('/version/' + versionId, {
+          status: state
+        });
+        consola.success('设置状态成功');
+      } catch (e) {
+        logError('设置状态失败', e);
+      }
+      break;
+    }
+    case 'set-config': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      const config = await input({ message: '输入配置Json' });
+      try {
+        await axiosInstance.put('/version/' + versionId, {
+          config: config,
+        });
+        consola.success('设置配置成功');
+      } catch (e) {
+        logError('设置配置失败', e);
+      }
+      break;
+    }
+    case 'set-active-app-update': {
+      const versionId = await input({ message: '输入版本ID' });
+      const updateId = await input({ message: '输入更新ID' });
+      const isNext = await confirm({ message: 'Set as next active?', default: false });
+
+      try {
+        await axiosInstance.post('/update/active/app', { versionId, updateId, isNext });
+        consola.success('成功');
+      } catch (e) {
+        logError('失败', e);
+      }
+      break;
+    }
+    case 'set-active-web-update': {
+      const versionId = await input({ message: '输入版本ID' });
+      const updateId = await input({ message: '输入更新ID' });
+
+      try {
+        await axiosInstance.post('/update/active/web', { versionId, updateId });
+        consola.success('成功');
+      } catch (e) {
+        logError('失败', e);
+      }
+      break;
+    }
+    default:
+      logError('未知参数', action);
+      break;
+  }
+}
+
+//选择方法
+//========================================
+
+export async function selectVersion(defaultVersionId = null) {
+  const data = (await axiosInstance.get('/version/list?full=true&search=' + JSON.stringify({ 
+    appId: config.appId 
+  }))).data;
+  if (data.length === 0) {
+    logError('没有版本,请在管理后台创建 appId ' + config.appId + ' 的版本');
+    return;
+  }
+  const versionId = (await select({
+    choices: data.map(p => ({
+      value: p.id,
+      name: p.version,
+    })),
+    default: defaultVersionId,
+    message: '选择一个版本',
+  }));
+  const versionName = data.find(p => p.id === versionId).version;
+  return { versionId, versionName };
+}
+export async function selectUpdate() {
+  const { versionId } = await selectVersion();
+  const data = (await axiosInstance.get('/version/update?search=' + JSON.stringify({ versionId }))).data;
+  if (data.length === 0) {
+    logError('没有更新');
+    return;
+  }
+  const resultId = (await select({
+    choices: data.map(p => ({
+      value: p.id,
+      name: p.version,
+    })),
+    message: '选择一个更新',
+  }));
+  return resultId;
+}
+
+//更新相关
+//========================================
+
+const stateConstant = [ 'Deleted', 'Normal', 'Deprecated' ];
+const typeConstant = [ 'Unknow', 'Web', 'app' ];
+const storageTypeConstant = [ 'Unknow', 'LocalStorage', 'AliOSS' ];
+
+export async function postUpdate(type, options) {
+  switch (type) {
+    case 'web': {
+      await postWebUpdate(axiosInstance, options);
+      break;
+    }
+    case 'app': {
+      await postAppUpdate(axiosInstance, options);
+      break;
+    }
+    default:
+      logError('Unknow type', type);
+      break;
+  }
+}
+export async function deprecateOrDeleteUpdate(updateId) {
+  if (!updateId)
+    updateId = await selectUpdate();
+
+  const { currentUpdateInfo, currentVersionInfo } = await viewUpdate(updateId);
+
+  const deprecate = (await select({
+    choices: [
+      {
+        name: 'Deprecate',
+        value: 0,
+      },
+      {
+        name: 'Delete',
+        value: 1,
+      },
+    ],
+    message: '删除或弃用?',
+  })) === 0;
+
+  if (deprecate && currentUpdateInfo.type !== 1) {
+    consola.warn('只有Web更新允许弃用');
+    return;
+  }
+  if (currentUpdateInfo.status === 0) {
+    consola.warn(`当前状态 ${stateConstant[currentUpdateInfo.status]} 无法弃用`);
+    return;
+  }
+
+  if (deprecate) {
+    if (!await confirm({ message: `确定弃用当前版本 ${currentUpdateInfo.versionCode} ?弃用会删除存储文件以及备份。此操作无法恢复!`, default: false }))
+      return;
+  } else {
+    if (!await confirm({ message: `确定删除当前版本 ${currentUpdateInfo.versionCode} ?此操作无法恢复!`, default: false }))
+      return;
+  }
+
+  try {
+    await axiosInstance.post(`/update/${deprecate ? 'deprecate' : 'delete'}`, { updateId });
+    consola.success(`${deprecate ? '弃用' : '删除'} 成功`);
+  } catch (e) {
+    logError(`操作失败`, e);
+  }
+}
+export async function viewUpdate(updateId) {
+  
+  let currentUpdateInfo = null
+  let currentVersionInfo = null
+
+  try {
+    currentUpdateInfo = (await axiosInstance.get('/update/' + updateId)).data;
+  } catch (e) {
+    logError('加载更新信息失败', updateId);
+  }
+  try {
+    currentVersionInfo = (await axiosInstance.get('/version/' + currentUpdateInfo.versionId)).data;
+  } catch (e) {
+    logError('加载版本信息失败', currentUpdateInfo.versionId);
+  }
+
+  const table = new Table({
+    head: ['key', 'data'], 
+    colWidths: [20, 40]
+  });
+  table.push(
+    [ 'ID', currentUpdateInfo.id ],
+    [ '所属应用', currentUpdateInfo.name ],
+    [ '更新信息', currentUpdateInfo.updateInfo ],
+    [ '版本号', currentUpdateInfo.versionCode ],
+    [ '创建时间', new Date(currentUpdateInfo.createAt).toString() ],
+    [ '类型', typeConstant[currentUpdateInfo.type] ],
+    [ '状态', stateConstant[currentUpdateInfo.status] ],
+    [ '强制更新', currentUpdateInfo.force ],
+    [ '公共访问路径', currentUpdateInfo.publicUrl ],
+    [ '存储类型', storageTypeConstant[currentUpdateInfo.storageType] ],
+    [ '存储路径', currentUpdateInfo.storagePath ],
+  );
+
+  console.log(table.toString());
+
+  if (currentUpdateInfo.activeWebVersionName) {
+    table.push(
+      [ '使用中的Web版本', currentUpdateInfo.activeWebVersionName ],
+    );
+  }
+  if (currentUpdateInfo.activeAppVersionName) {
+    table.push(
+      [ '使用中的App版本', currentUpdateInfo.activeAppVersionName ],
+    );
+  }
+
+  return {
+    currentUpdateInfo,
+    currentVersionInfo,
+  }
+}
+export async function getUpdate(action, type, all, options) {
+  const typeNotANumber = isNaN(new Number(type)); 
+
+  if (action === 'view' && (!type || typeNotANumber)) {
+
+    let hasSerch = false;
+    const search = {};
+    const sort = {
+      field: "createAt",
+      order: "descend"
+    }
+    if (typeNotANumber && type !== 'all') {
+      hasSerch = true;
+      search.version = type;
+    }
+
+    const data = await axiosInstance.get('/update/list?full=true' + (hasSerch ? ('&search=' + JSON.stringify(search)) : '') + '&sort=' + JSON.stringify(sort));
+    const table = new Table({
+      head: ['ID', '版本', '版本号', '类型', '状态'], 
+      colWidths: [10, 10, 20, 10, 15 ]
+    });
+
+    if (type !== 'all' && all !== 'all' && data.data.length > 10) {
+      data.data = data.data.slice(0, 10);
+      consola.info('filter!', all);
+    }
+
+    data.data.forEach((d) => {
+      table.push([ 
+        d.id, 
+        (d.activeAppVersionName ? `${d.activeAppVersionName} (App)` : (
+          d.activeWebVersionName? `${d.activeWebVersionName} (Web)` : '无'
+        )),  
+        d.versionCode, typeConstant[d.type], stateConstant[d.status] 
+      ]);
+    })
+    
+    console.log(table.toString());
+  } else {
+    switch (action) {
+      case 'post': {
+        await postUpdate(type, options);
+        break;
+      }
+      case 'delete': {
+        await deprecateOrDeleteUpdate(type);
+        break;
+      }
+      case 'view': {
+        viewUpdate(type);
+        break;
+      }
+      default: 
+        logError('Unknow action', action);
+        break;
+    }
+  }
+}
+export async function testVersion(version) {
+  try {
+    const data = await axiosInstance.get('/update-get-info?version=' + version);
+    consola.info('版本信息', data.data);
+  } catch(e) {
+    logError('获取失败', e);
+  }
+}

+ 273 - 0
scripts/UpdateScript/auth.mjs

@@ -0,0 +1,273 @@
+/**
+ * 更新发布工具 - 认证与服务器连接模块
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import axios from 'axios';
+import md5 from 'md5';
+import { password, input } from '@inquirer/prompts';
+import { config } from './postConfig.mjs';
+import consola from 'consola';
+import { logError } from './utils.mjs';
+
+// 基础配置
+// ========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+export const constant = {
+  ServerUrl: config.server,
+  TokenSave: path.resolve(__dirname, './_token.json'),
+};
+
+let currentData = {
+  token: '',
+  refreshToken: '',
+  identifier: '',
+};
+
+function initIdentifier() {
+  if (!currentData.identifier)
+    currentData.identifier = `commandClient${Math.floor(Math.random() * 1000)}`;
+}
+
+function getErrorMessage(e) {
+  return e instanceof Error ? e.message : (typeof e === 'object' ? e : '' + e);
+}
+
+async function saveCurrentData() {
+  await writeFile(constant.TokenSave, JSON.stringify(currentData));
+}
+
+// Axios 实例
+// ========================================
+
+export const axiosInstance = axios.create({
+  baseURL: constant.ServerUrl,
+  timeoutErrorMessage: '请求超时,请检查网络连接',
+  responseType: 'json',
+  withCredentials: false,
+  validateStatus: () => true,
+});
+
+axiosInstance.interceptors.request.use((value) => {
+  value.headers['authorization'] = JSON.stringify({
+    auth: currentData?.token?.authName,
+    validity: currentData?.token?.authKey,
+    nonce: 'aaaaaaaaaa',
+    identifier: currentData.identifier,
+    key: 'abc123',
+  });
+  value.url =
+    value.url +
+    (value.url.includes('?') ? '&' : '?') +
+    `identifier=${currentData.identifier}`;
+  return value;
+});
+
+let refreshPromise = null;
+
+async function tryRefreshToken() {
+  if (!currentData.refreshToken) return false;
+  if (refreshPromise) return refreshPromise;
+  refreshPromise = (async () => {
+    try {
+      const res = await axios.post(
+        `${constant.ServerUrl}auth/refresh?identifier=${currentData.identifier}`,
+        { refreshToken: currentData.refreshToken },
+        { responseType: 'json', validateStatus: () => true }
+      );
+      if (res.data && res.data.success) {
+        currentData.token = {
+          authName: res.data.data.authName,
+          authKey: res.data.data.authKey,
+        };
+        if (res.data.data.refreshToken)
+          currentData.refreshToken = res.data.data.refreshToken;
+        await saveCurrentData();
+        return true;
+      } else
+        return false;
+    } catch {
+      // 刷新失败
+    }
+    return false;
+  })();
+  try {
+    return await refreshPromise;
+  } finally {
+    refreshPromise = null;
+  }
+}
+
+axiosInstance.interceptors.response.use((value) => {
+  if (value.data.success) 
+    return value.data;
+  const err = value.data;
+  err.__config = value.config;
+  return Promise.reject(err);
+}, (error) => {
+  return Promise.reject(error);
+});
+
+axiosInstance.interceptors.response.use(null, async (error) => {
+  const code = error?.code;
+  if (
+    (code === 40102 || code === 40103) &&
+    currentData.refreshToken &&
+    !error.__retried
+  ) {
+    const refreshed = await tryRefreshToken();
+    if (refreshed) {
+      const cfg = error.__config || error.config;
+      if (cfg) {
+        error.__retried = true;
+        return axiosInstance.request(cfg);
+      }
+    }
+  }
+  return Promise.reject(error);
+});
+
+// 认证初始化
+// ========================================
+
+/**
+ * 初始化认证模块,从本地加载 token,并检查登录状态
+ * @returns {Promise<void>}
+ */
+export async function initAuth() {
+  try {
+    const res = await readFile(constant.TokenSave);
+    const parsed = JSON.parse(res);
+    if (parsed && typeof parsed === 'object') {
+      currentData = { ...currentData, ...parsed };
+    }
+  } catch {
+    // 无 token 文件时使用默认值
+  }
+  initIdentifier();
+
+  if (currentData.token) {
+    try {
+      const res = await axiosInstance.get('/user/user-info');
+      if (res?.data) {
+        consola.info(`当前登录用户: ${res.data.name || res.data.user} (ID: ${res.data.id})`);
+        return;
+      }
+    } catch {
+      // 登录已过期,尝试刷新 token
+    }
+    if (currentData.refreshToken) {
+      consola.info('尝试刷新 token');
+      try {
+        const res = await tryRefreshToken();
+        if (res) {
+          consola.success('刷新登录成功');
+          return;
+        }
+      } catch (e) {
+        logError('刷新 token 失败', e);
+      }
+    }
+  }
+
+  consola.warn('未登录,进入登录界面');
+  await interactiveLogin();
+}
+
+/**
+ * 获取当前认证数据(只读)
+ */
+export function getCurrentData() {
+  return { ...currentData };
+}
+
+// 登录相关
+// ========================================
+
+/**
+ * 执行登录请求并保存 token
+ * @param {string} user 用户名
+ * @param {string} pass 密码
+ */
+async function doLogin(user, pass) {
+  const res = await axiosInstance.post('/auth?rember=true', {
+    method: 'key',
+    key: `${user}@${md5(pass)}`,
+  });
+  currentData.token = {
+    authName: res.data.authName,
+    authKey: res.data.authKey,
+  };
+  if (res.data.refreshToken)
+    currentData.refreshToken = res.data.refreshToken;
+  await saveCurrentData();
+}
+
+/**
+ * 交互式登录,循环直至登录成功或用户取消
+ */
+async function interactiveLogin() {
+  while (true) {
+    try {
+      const user = await input({ message: '输入用户名' });
+      const pass = await password({ message: '输入密码' });
+      await doLogin(user, pass);
+      consola.success('登录成功');
+      return;
+    } catch (e) {
+      if (e?.name === 'ExitPromptError' || e?.name === 'CancelPromptError')
+        return;
+      logError('登录失败', getErrorMessage(e));
+    }
+  }
+}
+
+/**
+ * 检查登录状态
+ */
+export async function checkLogged() {
+  try {
+    await axiosInstance.get('/auth');
+    consola.success('已登录');
+  } catch (e) {
+    logError('获取状态失败:', getErrorMessage(e));
+  }
+}
+
+/**
+ * 登录
+ * @param {string} user 用户名
+ */
+export async function login(user) {
+  try {
+    const username = user || await input({ message: '输入用户名' });
+    const pass = await password({ message: '输入密码' });
+    await doLogin(username, pass);
+    consola.success('登录成功');
+  } catch (e) {
+    logError('登录失败', getErrorMessage(e));
+  }
+}
+
+/**
+ * 退出登录
+ */
+export async function logout() {
+  currentData.token = '';
+  currentData.refreshToken = '';
+  await saveCurrentData();
+  try {
+    await axiosInstance.delete('/auth');
+    consola.success('退出登录成功');
+  } catch (e) {
+    logError('退出登录失败', getErrorMessage(e));
+  }
+}

+ 22 - 0
scripts/UpdateScript/deprecate.html

@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>提示</title>
+    <style>.global-error{position:fixed;left:0;right:0;bottom:100px;top:0;display:flex;flex-direction:column;justify-content:center;align-items:center;background-color:#fff;-webkit-app-region:drag}.global-error span{margin-top:20px;font-size:16px}button{margin-top:20px;padding:0;display:inline-block;-webkit-app-region:no-drag;height:40px;width:120px;appearance:none;outline:none;border:none;background-color:#0083da;color:#fff;cursor:pointer}button:hover{background-color:#005c99}</style>
+  </head>
+  <body>
+    <script>
+      function relaunch() {
+        if (typeof api.relaunch == 'function') 
+          api.relaunch();
+        else
+          location.reload();
+      }
+    </script>
+    <div class="global-error">
+      <span>系统已更新,请重启</span>
+      <button id="load-confirm-button" onclick="relaunch()">确定</button>
+    </div>
+</html>

+ 63 - 0
scripts/UpdateScript/index.mjs

@@ -0,0 +1,63 @@
+/**
+ * 更新发布工具
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { program } from 'commander';
+import { login, checkLogged, logout, initAuth } from './auth.mjs';
+import { testVersion, getVersion, getUpdate } from './app.mjs';
+import { logError } from './utils.mjs';
+
+//程序入口
+//============================================
+
+program
+  .command('login <user>')
+  .description('登录')
+  .action(login);
+program
+  .command('logstate')
+  .description('检查登录状态')
+  .action(checkLogged);
+program
+  .command('logout')
+  .description('退出登录')
+  .action(logout);
+
+program
+  .command('test <version>')
+  .description('测试更新入口')
+  .action(testVersion);
+
+program
+  .command('version <action> [type]')
+  .description('查看版本信息/发布版本/删除版本/设置版本, action 可选 view/new/delete/set-web-update/set-app-update/set-next-app-update')
+  .action(getVersion);
+program
+  .command('update <action> [type] [all]')
+  .description('查看更新信息/发布更新, action 可选 view/post, view type 可选 id/all; post type 可选 web/app')
+  .option('--skip', '跳过构建')
+  .option('--ndelete', '不删除构建文件')
+  .action(getUpdate);
+
+function start() {
+  program.parse(process.argv);
+}
+
+initAuth().then(start);
+
+process.on('unhandledRejection', (reason) => {
+  let reasonStr = '';
+  if (typeof reason === 'string') {
+    reasonStr = reason;
+  } else if (typeof reason === 'object') {
+    if (reason.message) {
+      reasonStr = reason.message;
+    }
+  } else {
+    reasonStr = reason.toString();
+  }
+  logError('UnhandledRejection:', reasonStr)
+})
+

+ 76 - 0
scripts/UpdateScript/postConfig.mjs

@@ -0,0 +1,76 @@
+import { readFileSync, existsSync } from 'node:fs';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pad } from './utils.mjs';
+import path from 'node:path';
+import JSON5 from 'json5';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const localConfigPath = path.resolve(__dirname, './_localConfig.json');
+const localConfig = existsSync(localConfigPath) ? JSON.parse(readFileSync(localConfigPath, 'utf8')) : {};
+
+//提交更新配置
+//========================================
+
+export const config = {
+  server: localConfig.updateServer || 'https://update-server1.imengyu.top/',
+  submitKey: '',
+  appId: 2,
+  uploadWebConfig: {
+    storageAction: 'override',
+    storageProps: {
+      overrideMode: 'overrideFiles',
+      overrideFiles: [
+        'index.html',
+      ],
+      newFolderNameGenerateType: 'hash',
+    },
+    deprecateConfig: {
+      indexFile: 'index.html',
+      updateHtml: readFileSync(path.resolve(__dirname, './deprecate.html'), 'utf8')
+    },
+  },
+  buildWebCommand: 'npm run build-only', //构建命令
+  buildWebOutDir: '../../dist', //构建输出目录。相对于当前文件目录  
+  buildWebOptions: {
+    skipFiles: [
+    ], //打包忽略文件,相对于 buildWebOutDir ,判断开头
+  },
+  buildWebOutVersionPath: '', //版本号输出目录,输出版本号至文件以供项目使用。相对于当前文件目录
+  getCustomConfig: async (param, isApp, versionName) => {
+    return {}//额外的自定义配置
+  },
+  /**
+   * 自定义生成Web版本号的方法。
+   * @param {Date} now 当前日期
+   * @param {Number} lastTodaySubVersion 今天上传的之前版本数量
+   * @returns 
+   */
+  buildWebVersionGenerateCommand: async (now, lastTodaySubVersion) => {
+    //生成Web版本号
+    const version = `${now.getFullYear().toString().substring(2)}${pad(now.getMonth() + 1, 2)}${pad(now.getDate(), 2)}.${pad(lastTodaySubVersion, 2)}`;
+    return version;
+  },
+  buildAppCallback: async (param, versionCode, versionName, lastTodaySubVersion) => {
+    //构建App
+    throw new Error('未实现buildAppCallback方法');
+  }, 
+  buildAppGetUploadFile: async (param, versionCode, versionName) => {
+    //获取上传文件路径
+    throw new Error('未实现buildAppGetUploadFile方法');
+  }, 
+  buildAppGetOSSFileName: async (param, versionCode, versionName) => {
+    //生成OSS保存路径
+    throw new Error('buildAppGetOSSFileName');
+  },//构建命令
+  buildAppOutDir: './dist', //构建输出目录。相对于当前文件目录
+  buildAppGetVersion: async (versionName) => {
+    //获取版本号
+    throw new Error('未实现buildAppGetVersion方法');
+  },
+} 

+ 424 - 0
scripts/UpdateScript/postUpdate.mjs

@@ -0,0 +1,424 @@
+/**
+ * 更新发布工具
+ * 
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { confirm, select, input } from '@inquirer/prompts';
+import { writeFile, readFile, access, unlink, stat, constants } from 'node:fs/promises';
+import path from 'node:path';
+import OSS from 'ali-oss';
+import cliProgress from 'cli-progress';
+import { selectVersion } from './app.mjs';
+import { config } from './postConfig.mjs';
+import { readFileRange, compressZip, execAsync, logError } from './utils.mjs';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import consola from 'consola';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const UPLOAD_APP_TYPE_LOCAL = 1;
+const UPLOAD_APP_TYPE_ALI_OSS = 2;
+
+async function getConfig() {
+  let postConfig = {
+    lastVersion: '',
+    lastUpdateInfo: '',
+    lastSubmitDay: '',
+    lastTodaySubVersion: 0,
+  };
+  try {
+    postConfig = JSON.parse(await readFile(path.resolve(__dirname, './_config.json')));
+  } catch {
+    //
+  }
+  if (postConfig.lastSubmitDay != new Date().getDate()) 
+    postConfig.lastTodaySubVersion = 0;
+  return postConfig;
+}
+async function saveConfig(postConfig) {
+  postConfig.lastSubmitDay = new Date().getDate();
+  await writeFile(path.resolve(__dirname, './_config.json'), JSON.stringify(postConfig));
+}
+
+async function getUpdateInfo(postConfig) {
+  let lastUseGit = false;
+  let updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo });
+  if (updateInfo === 'git') {
+    consola.info('开始获取git提交信息');
+    lastUseGit = true;
+    updateInfo = await execAsync('git log -1 --pretty=format:"%h %s"');
+    consola.info('使用git提交信息 "' + updateInfo + '" 作为更新信息');
+  }
+  return { updateInfo, lastUseGit };
+}
+export async function uploadMulitPartLarge(axiosInstance, fileInfo, filePath) {
+
+  consola.start('开始分片上传');
+
+  const multuploadInfo = (await axiosInstance.post('/update/update-large-token', {
+    fileSize: fileInfo.size,
+    fileName: path.basename(filePath)
+  })).data;
+  const chunkSize = multuploadInfo.splitPartSize;
+
+  const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
+  bar1.start(100, 0);
+
+  for (let i = 0; i < multuploadInfo.allChunks; i++) {
+    const start = i * chunkSize;
+    const len = Math.min(start + chunkSize, fileInfo.size) - start;
+    const uploadZipContent = await readFileRange(filePath, start, len);
+    const subFormData = new FormData();
+    subFormData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
+    subFormData.append("key", multuploadInfo.key);
+    subFormData.append("info", JSON.stringify({}));
+    
+    (await axiosInstance.post('/update/update-large', subFormData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })).data;
+    
+    bar1.update(Math.floor(i / multuploadInfo.allChunks * 100));
+  }
+
+  bar1.update(100);
+  bar1.stop();
+
+  return multuploadInfo.key;
+}
+async function aliOSSMultipartUpload(client, fileName, uploadFile, progressCallback) {
+  
+  const fileInfo = await stat(uploadFile); // 获取文件信息
+  const partSize = 10 * 1024 * 1024; // 分片大小,这里使用10MB
+  const partCount = Math.ceil(fileInfo.size / partSize); // 计算总分片数
+  
+  consola.start(`开始分片上传到阿里OSS,文件大小: ${(fileInfo.size / 1024 / 1024).toFixed(2)}MB,分片数: ${partCount}`);
+  
+  try {
+    // 初始化分片上传
+    const multipartUpload = await client.initMultipartUpload(fileName);
+    const uploadId = multipartUpload.uploadId;
+    
+    // 上传分片
+    const parts = [];
+    for (let i = 0; i < partCount; i++) {
+      const start = i * partSize;
+      const len = Math.min(partSize, fileInfo.size - start);
+      
+      // 读取文件分片
+      const fileData = await readFileRange(uploadFile, start, len);
+      
+      // 上传分片
+      const partResult = await client.uploadPart(fileName, fileData, uploadId, i + 1);
+      parts.push({
+        ETag: partResult.res.headers.etag,
+        PartNumber: i + 1
+      });
+      
+      // 计算并回调进度
+      const progress = (i + 1) / partCount;
+      if (progressCallback) {
+        progressCallback(progress);
+      }
+      
+      consola.info(`已上传分片 ${i + 1}/${partCount} (${(progress * 100).toFixed(2)}%)`);
+    }
+    
+    // 完成分片上传
+    await client.completeMultipartUpload(fileName, uploadId, parts);
+    
+    consola.success('阿里OSS分片上传完成');
+    
+    // 获取上传后的文件URL
+    const url = await client.generateObjectUrl(fileName);
+    return url;
+  } catch (error) {
+    logError('阿里OSS分片上传失败:', error);
+    throw error;
+  }
+}
+
+//App更新与提交
+//========================================
+
+export async function postAppUpdate(axiosInstance, param) {
+  const postConfig = await getConfig();
+  const { versionId, versionName } = await selectVersion(postConfig.lastVersion);
+  const { updateInfo, lastUseGit } = await getUpdateInfo(postConfig);
+
+  const serverConfig = await axiosInstance.post('/update/update-post', { 
+    config: { 
+      type: 2, 
+      test: true, 
+      versionId, 
+      uploadAppConfig: {}, 
+      submitKey: config.submitKey 
+    } 
+  });
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = lastUseGit ? 'git': updateInfo;
+  postConfig.lastTodaySubVersion++;
+
+  const updateSource = (await select({
+    choices: [
+      {
+        name: '重新构建',
+        value: 'rebuild',
+      },
+      {
+        name: '已上传的阿里OSS文件路径',
+        value: 'uploaded-alioss',
+      },
+    ],
+    message: '选择上传来源',
+    default: 'rebuild',
+  }));
+
+  const force = await confirm({ message: '强制更新?', default: false });
+  const updateNext = await confirm({ message: '作为下个版本?', default: false });
+  const versionCode = await config.buildAppGetVersion(versionName);
+
+  await saveConfig(postConfig);
+
+  if (updateSource === 'rebuild') 
+    await config.buildAppCallback(param, versionCode, versionName, postConfig.lastTodaySubVersion);
+  else if (updateSource === 'uploaded-alioss') {
+    const fileName = await input({ message: '输入已上传的阿里OSS文件路径' });
+    try {
+      const result = (await axiosInstance.post('/update/update-post', {
+        type: 2,
+        ossConfig: {
+          ossPath: fileName,
+          ossPublic: '',
+        },
+        uploadAppConfig: {
+          updateAsNext: updateNext,
+        },
+        versionId,
+        updateInfo,
+        versionCode: versionCode
+      })).data;
+      consola.success('上传成功');
+      consola.info('新更新ID: ' + result.updateId);
+    } catch (e) {
+      logError('上传失败', e);
+    }
+    return;
+  }
+  else {
+    logError('错误的选择');
+    return;
+  }
+
+  const uploadFile = await config.buildAppGetUploadFile(param, versionCode, versionName);
+  const fileName = `${await config.buildAppGetOSSFileName(param, versionCode, path.basename(uploadFile), uploadFile)}`;
+  try {
+    await access(uploadFile, constants.R_OK)
+  } catch {
+    logError(`Failed to access ${uploadFile}, did you created it?`);
+    return;
+  }
+
+  consola.start('开始上传');
+
+  //小于8mb则小文件上传,否则使用阿里OSS上传
+  const fileInfo = await stat(uploadFile);
+  if (fileInfo.size < 8 * 1024 * 1024) {
+    const appData = (await readFile(uploadFile));
+
+    const formData = new FormData();
+    formData.append("file", new Blob([ appData ], { type : 'application/zip' }));
+    formData.append("config", JSON.stringify({
+      type: 2,
+      versionId,
+      versionCode,
+      updateInfo,
+      updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+      uploadAppConfig: { updateAsNext: updateNext },
+      force,
+      fileName,
+    }));
+
+    try {
+      const result = (await axiosInstance.post('/update/update-post', formData, {
+        headers: { 'Content-Type': 'multipart/form-data' }
+      })).data;
+      consola.success('上传成功');
+      consola.info('新更新ID: ' + result.updateId);
+    } catch (e) {
+      logError('上传失败', e);
+    }
+  } else {
+
+    let ossConfig;
+    let multuploadedKey;
+
+    if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_LOCAL) {
+      //本地大文件上传
+      multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, uploadFile);
+    } else if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_ALI_OSS) {
+      //阿里OSS上传
+      //请求STS进行临时授权
+      const stsToken = (await axiosInstance.post('/update/update-ali-oss-sts')).data;
+      const client = new OSS({
+        region: stsToken.Region,
+        accessKeyId: stsToken.AccessKeyId,
+        accessKeySecret: stsToken.AccessKeySecret,
+        stsToken: stsToken.SecurityToken,
+        bucket: stsToken.Bucket,
+        refreshSTSToken: async () => {
+          const refreshToken = (await axiosInstance.get("/update/update-ali-oss-sts")).data;
+          return {
+            accessKeyId: refreshToken.AccessKeyId,
+            accessKeySecret: refreshToken.AccessKeySecret,
+            stsToken: refreshToken.SecurityToken,
+          };
+        },
+      });
+      
+      //小于96mb则直接上传,否则分片上传
+
+      consola.start('Start upload to ali oss');
+
+      let returnUrl = '';
+      if (fileInfo.size < 96 * 1024 * 1024) {
+        const result = await client.put(fileName, uploadFile);
+        returnUrl = result.url;
+      } else {
+        const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
+        bar1.start(100, 0);
+        returnUrl = await aliOSSMultipartUpload(client, fileName, uploadFile, (p) => {
+          bar1.update(p * 100);
+        });
+        bar1.update(100);
+        bar1.stop();
+      }
+
+      ossConfig = {
+        ossPath: fileName,
+        ossPublic: returnUrl,
+      };
+
+      consola.success('Upload to ali oss done');
+    } else {
+      logError('错误的 uploadAppType 配置 ' + serverConfig.uploadAppType);
+    }
+  
+    try {
+      const result = (await axiosInstance.post('/update/update-post', {
+        config: {
+          type: 2,
+          ossConfig,
+          multuploadedKey,
+          uploadAppConfig: {
+            updateAsNext: updateNext,
+          },
+          updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+          versionId,
+          updateInfo,
+          versionCode: versionCode,
+          fileName
+        },
+      })).data;
+      consola.success('上传成功');
+      consola.info('新更新ID: ' + result.updateId);
+    } catch (e) {
+      logError('上传失败', e);
+    }
+  }
+}
+
+//Web更新与提交
+//========================================
+
+export async function postWebUpdate(axiosInstance, param) {
+  const skipBuild = param.skip
+  const noDelete = param.ndelete;
+  
+  const postConfig = await getConfig();
+  const { versionId, versionName } = await selectVersion(postConfig.lastVersion);
+  const { updateInfo, lastUseGit } = await getUpdateInfo(postConfig);
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = lastUseGit ? 'git': updateInfo;
+  postConfig.lastTodaySubVersion++;
+
+  await axiosInstance.post('/update/update-post', { config: { type: 1, test: true, versionId, uploadWebConfig: config.uploadWebConfig, submitKey: config.submitKey } });
+
+  const now = new Date();
+  const versionCode = await config.buildWebVersionGenerateCommand(now, postConfig.lastTodaySubVersion);
+
+  if (config.buildWebOutVersionPath)
+    await writeFile(path.resolve(__dirname, config.buildWebOutVersionPath), versionCode);
+
+  await saveConfig(postConfig);
+  
+  if (!skipBuild && config.buildWebCommand) {
+    consola.start('正在执行构建...');
+    await execAsync(config.buildWebCommand);
+    consola.success('构建完成');
+  }
+
+  const distDir = path.resolve(__dirname, config.buildWebOutDir);
+
+  try {
+    await access(distDir, constants.R_OK)
+  } catch {
+    logError(`Failed to access ${distDir}`);
+    return;
+  }
+
+  const outputPath = __dirname + '/upload.zip';
+  const skipFiles = config?.buildWebOptions?.skipFiles ?? [];
+
+  if (!skipBuild) {
+    consola.start('开始压缩zip...');
+    await compressZip(distDir, outputPath, skipFiles);
+  }
+
+  consola.start('开始上传zip');
+
+  let success = false;
+
+  //小于8mb则小文件上传,否则分片上传
+  const fileInfo = await stat(outputPath);
+  const formData = new FormData();
+  const submitConfig = {
+    type: 1,
+    versionId,
+    updateInfo,
+    versionCode,
+    uploadWebConfig: config.uploadWebConfig,
+    updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+  }
+
+  if (fileInfo.size < 8 * 1024 * 1024) {
+    const uploadZipContent = await readFile(outputPath);
+    formData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
+  } else {
+    submitConfig.multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, outputPath);
+  }
+
+  try {
+    formData.append("config", JSON.stringify(submitConfig));
+    const result = (await axiosInstance.post('/update/update-post', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })).data;
+    consola.success('上传成功');
+    consola.info('新更新ID: ' + result.updateId);
+    success = true;
+  } catch (e) {
+    logError('上传失败', e);
+  }
+  if (!success || noDelete) 
+    return;
+    
+  consola.info('删除zip');
+  await unlink(outputPath);
+}

+ 141 - 0
scripts/UpdateScript/utils.mjs

@@ -0,0 +1,141 @@
+import fs from 'fs';
+import { ZipArchive } from 'archiver';
+import { exec } from 'child_process';
+import { readdir, stat } from 'fs/promises';
+import consola from 'consola';
+
+export function readFileRange(file, start, length) {
+  return new Promise((resolve, reject) => {
+    fs.open(file, 'r', (err, fd) => {
+      if (err) {
+        reject('Error opening file:', er);
+        return;
+      }
+      const buffer = Buffer.alloc(length);
+      fs.read(fd, buffer, 0, length, start, (err, bytesRead, buffer) => {
+        if (err) {
+          reject('Error reading file:', err)
+          return;
+        }
+        fs.close(fd, (err) => {
+          if (err) {
+            reject('Error closing file:', err)
+            return ;
+          }
+          resolve(buffer);
+        });
+      });
+    });
+  })
+  
+}
+export function pad(num, n) {
+  var strNum = num.toString();
+  var len = strNum.length;
+  while (len < n) {
+    strNum = "0" + strNum;
+    len++;
+  }
+  return strNum;
+}
+
+/**
+ * 异步执行命令
+ * @param {string} command 命令
+ * @returns {Promise<string>} 命令执行结果
+ */
+export function execAsync(command) {
+  return new Promise((resolve, reject) => {
+    consola.info('[CMD] ' + command);
+    exec(command, (error, stdout, stderr) => {
+      if (error) {
+        reject(error);
+        return;
+      }
+      resolve(stdout);
+    });
+  })
+}
+
+/**
+ * 记录错误信息
+ * @param {string} message 错误信息
+ * @param {*} e 错误对象
+ */
+export function logError(message, e) {
+  consola.error(message, e || '');
+}
+
+/**
+ * 获取错误信息
+ * @param {*} e 错误对象
+ * @returns {string} 错误信息
+ */
+export function getErrorMessage(e) {
+  if (e instanceof Error) 
+    return e.message;
+  if (typeof e === 'object' && e.message)
+    return e.message;
+  return '' + e;
+}
+
+/**
+ * 压缩zip
+ * @param {string} dir 压缩目录
+ * @param {string} targetFilePath 压缩文件路径
+ * @param {string[]} skipFiles 压缩忽略文件 支持按开头匹配,和文件类型通配符匹配,如:'*.js', '*.css', '*.html'
+ * @returns {Promise<void>}
+ */
+export async function compressZip(dir, targetFilePath, skipFiles) {
+  const output = fs.createWriteStream(targetFilePath);
+  const archive = new ZipArchive({
+    zlib: { level: 9 } // Sets the compression level.
+  });
+  archive.pipe(output);
+
+  function checkPathSkip(pathString) {
+    if (!pathString)
+      return true;
+    if (pathString.startsWith('.') || pathString.startsWith('/.'))
+      return true;
+    if (!skipFiles || skipFiles.length === 0)
+      return;
+    return skipFiles.find((item) => (
+      pathString.startsWith(item) || pathString.startsWith(`/${item}`)
+      || (item.includes('*.') && pathString.endsWith(item.split('*')[1]))
+    ));
+  }
+
+  async function loopDir(path, subPrefix) {
+    const files = await readdir(path);
+    for (const file of files) {
+      const subPath = subPrefix + '/' + file;
+      if (checkPathSkip(subPath))
+        continue;
+      consola.info('File: ' + subPath);
+      const filestat = await stat(dir + subPath);
+      if (filestat.isDirectory()) {
+        await loopDir(dir + subPath, subPath);
+      } else {
+        archive.file(dir + subPath, { name: subPath });
+      }
+    }
+  }
+  await loopDir(dir, '')
+
+  consola.start('等待压缩zip...');
+
+  const waitArchive = new Promise((resolve, reject) => {
+    output.on('close', function() {
+      consola.info(archive.pointer() + ' total bytes');
+      consola.success('archiver has been finalized and the output file descriptor has closed.');
+      resolve();
+    });
+    archive.on('error', function(err) {
+      reject(err);
+    });
+  })
+
+  await archive.finalize();
+  await waitArchive;
+}

+ 7 - 0
src/App.vue

@@ -0,0 +1,7 @@
+<script setup lang="ts">
+import { RouterView } from 'vue-router'
+</script>
+
+<template>
+  <RouterView />
+</template>

+ 80 - 0
src/assets/base.css

@@ -0,0 +1,80 @@
+/* 主题色变量 —— 闽南红砖 / 金漆配色 */
+:root {
+  --brick: #b64d3c;
+  --brick-deep: #62302d;
+  --gold: #e5bd72;
+  --sand: #f5e5bd;
+  --ink: #2d1a1b;
+
+  --c-border: #7f4034;
+  --c-panel-bg: rgba(53, 24, 23, 0.75);
+  --c-title: #f5df9e;
+  --c-text: #f7edcd;
+  --c-text-dim: #c6a18c;
+  --c-accent: #e28a60;
+}
+
+html,
+body {
+  margin: 0;
+  min-height: 100vh;
+}
+
+body {
+  background: #24100f;
+  color: var(--c-text);
+  min-width: 1180px;
+  overflow-x: hidden;
+  font-family:
+    'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', 'STSong', 'SimSun',
+    serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+/* 呼吸光点 */
+@keyframes pulse-dot {
+  0%,
+  100% {
+    transform: scale(1);
+    opacity: 0.75;
+  }
+  50% {
+    transform: scale(1.75);
+    opacity: 1;
+  }
+}
+
+.pulse-dot {
+  animation: pulse-dot 1.8s infinite ease-in-out;
+}
+
+/* 描边绘制 */
+@keyframes draw {
+  from {
+    stroke-dashoffset: 480;
+  }
+  to {
+    stroke-dashoffset: 0;
+  }
+}
+
+.route {
+  stroke-dasharray: 12 9;
+  animation: draw 14s linear infinite;
+}
+
+/* 灯火闪烁 */
+@keyframes flicker {
+  0%,
+  100% {
+    opacity: 0.8;
+  }
+  50% {
+    opacity: 1;
+  }
+}
+
+.lantern {
+  animation: flicker 3s ease-in-out infinite;
+}

+ 3 - 0
src/assets/main.css

@@ -0,0 +1,3 @@
+@import 'tailwindcss';
+
+@import './base.css';

文件差異過大導致無法顯示
+ 159 - 0
src/assets/solar-icons.json


+ 42 - 0
src/components/DashboardLayout.vue

@@ -0,0 +1,42 @@
+<script setup lang="ts">
+import PageHeader from './PageHeader.vue'
+import PageFooter from './PageFooter.vue'
+
+// 页面骨架:主背景 + 遮罩 + 顶部导航 + 内容区 + 页脚
+defineProps<{
+  title?: string
+  subtitle?: string
+  footerMiddle?: string
+  footerRight?: string
+}>()
+</script>
+
+<template>
+  <main
+    class="relative min-h-screen bg-[#24100f]"
+    style="
+      background-image: radial-gradient(circle at 50% 32%, rgba(137, 47, 35, 0.38), transparent 38%),
+        linear-gradient(90deg, rgba(191, 143, 70, 0.05) 1px, transparent 1px),
+        linear-gradient(0deg, rgba(191, 143, 70, 0.04) 1px, transparent 1px);
+      background-size: auto, 42px 42px, 42px 42px;
+    "
+  >
+    <div
+      class="pointer-events-none absolute inset-0 opacity-20"
+      style="
+        background-image: repeating-linear-gradient(
+          135deg,
+          transparent 0 22px,
+          rgba(207, 159, 79, 0.18) 23px 24px,
+          transparent 25px 47px
+        );
+      "
+    ></div>
+
+    <div class="relative z-10 flex min-h-screen flex-col px-5 pb-4">
+      <PageHeader :title="title" :subtitle="subtitle" />
+      <slot />
+      <PageFooter :middle="footerMiddle" :right="footerRight" />
+    </div>
+  </main>
+</template>

+ 53 - 0
src/components/PageFooter.vue

@@ -0,0 +1,53 @@
+<script setup lang="ts">
+import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
+
+const props = withDefaults(
+  defineProps<{
+    /** 中间文案 */
+    middle?: string
+    /** 右侧静态文案;不传则显示实时时钟 */
+    right?: string
+  }>(),
+  {
+    middle: '数据来源:市级文化资源数据库 · 实时汇聚',
+    right: '',
+  },
+)
+
+// 左侧文字 —— 静态文案,后续可替换为后端配置
+const leftText = '漳州市文化和旅游局 · 文化遗产保护中心'
+
+const clock = ref('')
+let timer: number | undefined
+
+function tick() {
+  const d = new Date()
+  const pad = (n: number) => String(n).padStart(2, '0')
+  clock.value =
+    `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
+    ` ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+onMounted(() => {
+  if (!props.right) {
+    tick()
+    timer = window.setInterval(tick, 1000)
+  }
+})
+
+onBeforeUnmount(() => {
+  if (timer) window.clearInterval(timer)
+})
+
+const rightText = computed(() => props.right || clock.value)
+</script>
+
+<template>
+  <footer
+    class="mt-5 flex h-7 shrink-0 items-center justify-between border-t border-[#7f4034]/60 text-[10px] text-[#bb8e79]"
+  >
+    <span>{{ leftText }}</span>
+    <span>{{ middle }}</span>
+    <span>{{ rightText }}</span>
+  </footer>
+</template>

+ 88 - 0
src/components/PageHeader.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+import { RouterLink, useRoute } from 'vue-router'
+
+withDefaults(
+  defineProps<{
+    title?: string
+    subtitle?: string
+  }>(),
+  {
+    title: '闽南文化(漳州)生态保护区',
+    subtitle: '数字驾驶舱 · 守护一城古韵',
+  },
+)
+
+// 顶部导航 —— 与后端路由无关的静态配置,后续可改为从菜单接口读取
+const navItems = [
+  { label: '保护区概况', to: '/protection-overview' },
+  { label: '文化资源', to: '/culture-resources' },
+  { label: '传承体验', to: '/heritage-experience' },
+  { label: '文旅融合', to: '/culture-tourism' },
+  { label: '公众传播', to: '/public-communication' },
+  { label: '数字档案', to: '/digital-archives' },
+]
+
+const route = useRoute()
+</script>
+
+<template>
+  <header
+    class="relative -mx-5 flex min-h-[112px] w-[calc(100%+2.5rem)] shrink-0 items-center gap-8 overflow-hidden border-b border-[#c69b51]/70 bg-gradient-to-r from-[#2b1115] via-[#4b1d20] to-[#321317] px-7 py-4 shadow-[0_5px_22px_rgba(18,7,9,.35)] backdrop-blur-sm"
+  >
+    <div
+      class="pointer-events-none absolute inset-x-7 bottom-0 h-px bg-gradient-to-r from-transparent via-[#e0b86d]/80 to-transparent"
+    ></div>
+    <div
+      class="pointer-events-none absolute right-8 top-2 h-16 w-40 opacity-25"
+      style="
+        background-image: radial-gradient(circle, rgba(224, 184, 109, 0.55) 1px, transparent 1px);
+        background-size: 14px 14px;
+      "
+    ></div>
+
+    <div class="flex min-w-0 flex-1 items-center gap-5">
+      <div class="min-w-0">
+        <div
+          class="truncate text-[24px] font-bold tracking-[.18em] text-[#ead6a6] [text-shadow:0_0_10px_rgba(225,194,125,.16)]"
+        >
+          {{ title }}
+        </div>
+        <div v-if="subtitle" class="mt-1 text-xs tracking-[.32em] text-[#c9ac76]">
+          {{ subtitle }}
+        </div>
+      </div>
+    </div>
+
+    <nav class="relative z-10 flex shrink-0 items-center justify-end gap-5 text-base text-[#d8c18d]">
+      <RouterLink
+        to="/"
+        aria-label="首页"
+        title="首页"
+        class="grid h-9 w-9 place-items-center rounded text-[#f2c66f] transition hover:bg-[#b64d3c] hover:border-[#d3ae65]"
+      >
+        <svg aria-hidden="true" class="h-7 w-8" fill="none" viewBox="0 0 80 64" xmlns="http://www.w3.org/2000/svg">
+          <path d="M8 39C19 36 30 30 40 18C49 29 60 34 72 38" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2"></path>
+          <path d="M13 42V51M67 40V49M18 37C27 34 35 27 40 20C47 28 54 33 64 37" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"></path>
+          <path d="M7 55C22 45 34 49 46 54C57 59 67 56 73 48C77 43 76 37 71 35C66 33 62 37 63 41C64 44 67 45 69 43" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"></path>
+          <path d="M48 30V45" stroke="currentColor" stroke-linecap="round" stroke-width="1.8"></path>
+          <path d="M39 27H44V32H39Z" fill="#9f4026" stroke="#9f4026" stroke-linejoin="round"></path>
+          <path d="M19 37V43" stroke="#9f4026" stroke-linecap="round" stroke-width="1.8"></path>
+        </svg>
+      </RouterLink>
+
+      <RouterLink
+        v-for="item in navItems"
+        :key="item.to"
+        :to="item.to"
+        class="rounded border border-transparent px-3 py-3 transition"
+        :class="
+          route.path === item.to
+            ? 'border-[#d3ae65]! bg-[rgba(182,77,60,.82)] text-[#f0d9a0] shadow-[0_0_16px_rgba(182,77,60,.2)]'
+            : 'hover:border-[#d3ae65] hover:bg-[rgba(182,77,60,.82)] hover:text-[#f0d9a0] hover:shadow-[0_0_16px_rgba(182,77,60,.2)]'
+        "
+      >
+        {{ item.label }}
+      </RouterLink>
+    </nav>
+  </header>
+</template>

+ 33 - 0
src/components/PanelCard.vue

@@ -0,0 +1,33 @@
+<script setup lang="ts">
+withDefaults(
+  defineProps<{
+    title: string
+    subtitle?: string
+    padding?: string
+    shadow?: boolean
+  }>(),
+  {
+    subtitle: '',
+    padding: 'p-3',
+    shadow: false,
+  },
+)
+</script>
+
+<template>
+  <article
+    class="rounded-sm border border-[#7f4034] bg-[#351817]/75"
+    :class="[padding, shadow ? 'shadow-[inset_0_0_22px_rgba(194,77,54,.12)]' : '']"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-2">
+      <div>
+        <h2 class="font-bold text-[#f5df9e]">{{ title }}</h2>
+        <p v-if="subtitle" class="mt-1 text-[10px] tracking-widest text-[#c19b87]">
+          {{ subtitle }}
+        </p>
+      </div>
+      <slot name="extra" />
+    </div>
+    <slot />
+  </article>
+</template>

+ 25 - 0
src/components/ProgressBar.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts">
+withDefaults(
+  defineProps<{
+    percent: number
+    height?: string
+    trackClass?: string
+    barClass?: string
+  }>(),
+  {
+    height: 'h-2',
+    trackClass: 'bg-[#582722]',
+    barClass: 'bg-gradient-to-r from-[#b54a39] to-[#dfae52]',
+  },
+)
+</script>
+
+<template>
+  <div class="overflow-hidden rounded-full" :class="[height, trackClass]">
+    <div
+      class="h-full rounded-full transition-all"
+      :class="barClass"
+      :style="{ width: `${percent}%` }"
+    ></div>
+  </div>
+</template>

+ 25 - 0
src/main.ts

@@ -0,0 +1,25 @@
+import './assets/main.css'
+
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import { addCollection, Icon } from '@iconify/vue'
+import solarIcons from './assets/solar-icons.json'
+
+import App from './App.vue'
+import router from './router'
+import { VChart } from './plugins/echarts'
+import PanelCard from './components/PanelCard.vue'
+import ProgressBar from './components/ProgressBar.vue'
+
+addCollection(solarIcons)
+
+const app = createApp(App)
+
+app.use(createPinia())
+app.use(router)
+app.component('VChart', VChart)
+app.component('Icon', Icon)
+app.component('PanelCard', PanelCard)
+app.component('ProgressBar', ProgressBar)
+
+app.mount('#app')

+ 43 - 0
src/plugins/echarts.ts

@@ -0,0 +1,43 @@
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import {
+  BarChart,
+  LineChart,
+  PieChart,
+  ScatterChart,
+  RadarChart,
+  GaugeChart,
+} from 'echarts/charts'
+import {
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  TitleComponent,
+  ToolboxComponent,
+  DataZoomComponent,
+  MarkLineComponent,
+  MarkPointComponent,
+  DatasetComponent,
+} from 'echarts/components'
+import VChart from 'vue-echarts'
+
+use([
+  CanvasRenderer,
+  BarChart,
+  LineChart,
+  PieChart,
+  ScatterChart,
+  RadarChart,
+  GaugeChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  TitleComponent,
+  ToolboxComponent,
+  DataZoomComponent,
+  MarkLineComponent,
+  MarkPointComponent,
+  DatasetComponent,
+])
+
+export { VChart }

+ 45 - 0
src/router/index.ts

@@ -0,0 +1,45 @@
+import { createRouter, createWebHashHistory } from 'vue-router'
+import HomeView from '../views/home/HomeView.vue'
+
+const router = createRouter({
+  history: createWebHashHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: '/',
+      name: 'home',
+      component: HomeView,
+    },
+    {
+      path: '/protection-overview',
+      name: 'protection-overview',
+      component: () => import('../views/protection-overview/ProtectionOverviewView.vue'),
+    },
+    {
+      path: '/culture-resources',
+      name: 'culture-resources',
+      component: () => import('../views/culture-resources/CultureResourcesView.vue'),
+    },
+    {
+      path: '/heritage-experience',
+      name: 'heritage-experience',
+      component: () => import('../views/heritage-experience/HeritageExperienceView.vue'),
+    },
+    {
+      path: '/culture-tourism',
+      name: 'culture-tourism',
+      component: () => import('../views/culture-tourism/CultureTourismView.vue'),
+    },
+    {
+      path: '/public-communication',
+      name: 'public-communication',
+      component: () => import('../views/public-communication/PublicCommunicationView.vue'),
+    },
+    {
+      path: '/digital-archives',
+      name: 'digital-archives',
+      component: () => import('../views/digital-archives/DigitalArchivesView.vue'),
+    },
+  ],
+})
+
+export default router

+ 44 - 0
src/views/culture-resources/CultureResourcesView.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import DashboardLayout from '@/components/DashboardLayout.vue'
+
+import ResourceOverviewPanel from './components/ResourceOverviewPanel.vue'
+import ResourceSystemPanel from './components/ResourceSystemPanel.vue'
+import CultureGallerySection from './components/CultureGallerySection.vue'
+</script>
+
+<template>
+  <DashboardLayout>
+    <section class="mt-5 flex items-end justify-between">
+      <div>
+        <h1 class="mt-2 text-[30px] font-bold tracking-[.16em] text-[#fff0bd]">文化资源</h1>
+      </div>
+      <div class="flex gap-2 pb-1 text-xs">
+        <button
+          type="button"
+          class="rounded-sm border border-[#d1734e] bg-[#84352d] px-4 py-2 text-[#ffe1a0]"
+        >
+          资源总览
+        </button>
+        <button
+          type="button"
+          class="rounded-sm border border-[#79503c] bg-[#351817]/80 px-4 py-2 text-[#d6b49a]"
+        >
+          数据目录
+        </button>
+        <button
+          type="button"
+          class="rounded-sm border border-[#79503c] bg-[#351817]/80 px-4 py-2 text-[#d6b49a]"
+        >
+          成果地图
+        </button>
+      </div>
+    </section>
+
+    <section class="mt-5 grid grid-cols-12 gap-4">
+      <ResourceOverviewPanel class="col-span-4" />
+      <ResourceSystemPanel class="col-span-8" />
+    </section>
+
+    <CultureGallerySection />
+  </DashboardLayout>
+</template>

+ 38 - 0
src/views/culture-resources/components/CultureGallerySection.vue

@@ -0,0 +1,38 @@
+<script setup lang="ts">
+import { galleryItems } from '../data'
+</script>
+
+<template>
+  <section class="mt-4 border border-[#7f4034] bg-[#351817]/80 p-4">
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#df925f]" icon="solar:gallery-wide-linear" width="20" />
+        <h2 class="font-bold text-[#f5df9e]">文化影像</h2>
+      </div>
+      <span class="text-[10px] text-[#d8b45e]">精选档案影像</span>
+    </div>
+
+    <div class="mt-3 grid grid-cols-4 gap-3 text-xs">
+      <button
+        v-for="item in galleryItems"
+        :key="item.title"
+        type="button"
+        class="group overflow-hidden border border-[#704033] bg-[#2b1514] text-left transition hover:border-[#d9b257] hover:-translate-y-0.5"
+      >
+        <span class="relative block h-28 overflow-hidden" :class="item.headerBgClass">
+          <img
+            :alt="item.imageAlt"
+            class="h-full w-full object-cover opacity-70 transition duration-300 group-hover:scale-105 group-hover:opacity-90"
+            :src="item.image"
+          />
+          <span
+            class="absolute inset-0 grid place-items-center bg-[#351817]/0 text-[#fff0bd] opacity-0 transition group-hover:bg-[#351817]/55 group-hover:opacity-100"
+          >
+            <Icon icon="solar:eye-linear" width="19" />
+          </span>
+        </span>
+        <span class="block p-2 font-bold text-[#ecd9a5]">{{ item.title }}</span>
+      </button>
+    </div>
+  </section>
+</template>

+ 110 - 0
src/views/culture-resources/components/HeritageList.vue

@@ -0,0 +1,110 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { heritageEntries, heritageCategories, levels, districts, type HeritageEntry } from '../data'
+
+// 筛选条件
+const category = ref('all')
+const level = ref('all')
+const district = ref('all')
+
+const filtered = computed(() =>
+  heritageEntries.filter((e) => {
+    const matchCategory = category.value === 'all' || e.category === category.value
+    const matchLevel = level.value === 'all' || e.level === level.value
+    const matchDistrict = district.value === 'all' || e.districts.includes(district.value)
+    return matchCategory && matchLevel && matchDistrict
+  }),
+)
+
+// 点击条目反馈
+const feedback = ref('')
+
+function onEntryClick(entry: HeritageEntry) {
+  feedback.value = `已定位「${entry.name}」项目档案,可在数据目录中继续查阅详情。`
+}
+</script>
+
+<template>
+  <div class="mt-4 border-t border-[#84503a]/70 pt-3">
+    <div class="flex items-center justify-between">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#e3b85c]" icon="solar:list-check-linear" width="19" />
+        <h4 class="text-sm font-bold text-[#f1d99d]">重点非遗项目</h4>
+      </div>
+      <span class="text-[10px] text-[#c89d78]">已建档 · {{ filtered.length }} 项</span>
+    </div>
+
+    <div class="mt-3 grid grid-cols-3 gap-2 border border-[#704033] bg-[#2b1514]/75 p-2 text-[11px]">
+      <label class="text-[#c6a18c]">
+        十大门类
+        <select
+          v-model="category"
+          class="mt-1 w-full border border-[#82503b] bg-[#351817] px-2 py-1.5 text-[#f0d7a2] outline-none focus:border-[#d0734e]"
+        >
+          <option value="all">全部门类</option>
+          <option v-for="c in heritageCategories" :key="c" :value="c">{{ c }}</option>
+        </select>
+      </label>
+      <label class="text-[#c6a18c]">
+        级别
+        <select
+          v-model="level"
+          class="mt-1 w-full border border-[#82503b] bg-[#351817] px-2 py-1.5 text-[#f0d7a2] outline-none focus:border-[#d0734e]"
+        >
+          <option value="all">全部级别</option>
+          <option v-for="l in levels" :key="l" :value="l">{{ l }}</option>
+        </select>
+      </label>
+      <label class="text-[#c6a18c]">
+        区县
+        <select
+          v-model="district"
+          class="mt-1 w-full border border-[#82503b] bg-[#351817] px-2 py-1.5 text-[#f0d7a2] outline-none focus:border-[#d0734e]"
+        >
+          <option value="all">全部区县</option>
+          <option v-for="d in districts" :key="d" :value="d">{{ d }}</option>
+        </select>
+      </label>
+    </div>
+
+    <div class="mt-3 grid grid-cols-2 gap-2 text-xs">
+      <button
+        v-for="e in filtered"
+        :key="e.name"
+        type="button"
+        class="group flex gap-3 border border-[#704033] bg-[#351817] p-2.5 text-left transition hover:-translate-y-0.5 hover:border-[#d0734e] hover:bg-[#4a211d]"
+        @click="onEntryClick(e)"
+      >
+        <span class="relative h-16 w-20 shrink-0 overflow-hidden border border-[#9b5a42] bg-[#64342b]">
+          <img
+            :alt="e.name"
+            class="h-full w-full object-cover opacity-75 transition duration-300 group-hover:scale-110 group-hover:opacity-100"
+            :src="e.image"
+          />
+          <span class="absolute inset-0 bg-gradient-to-t from-[#351817]/55 to-transparent"></span>
+        </span>
+        <span class="min-w-0">
+          <span class="flex items-start justify-between gap-2">
+            <span class="font-bold text-[#f3dba1]">{{ e.name }}</span>
+            <Icon class="text-[#e3b85c]" :icon="e.icon" width="17" />
+          </span>
+          <span class="mt-2 block text-[#c6a18c]">{{ e.category }} · {{ e.level }}</span>
+          <span class="mt-1 block text-[#ad8370]">{{ e.districts.join('、') }} · {{ e.note }}</span>
+        </span>
+      </button>
+    </div>
+
+    <p
+      v-if="filtered.length === 0"
+      class="mt-3 border-l-2 border-[#d0734e] bg-[#4a211d] px-3 py-2 text-xs text-[#f0d7a2]"
+    >
+      未找到符合当前筛选条件的重点非遗项目。
+    </p>
+    <p
+      v-if="feedback"
+      class="mt-3 border-l-2 border-[#d0734e] bg-[#4a211d] px-3 py-2 text-xs text-[#f0d7a2]"
+    >
+      {{ feedback }}
+    </p>
+  </div>
+</template>

+ 33 - 0
src/views/culture-resources/components/RecommendationCard.vue

@@ -0,0 +1,33 @@
+<script setup lang="ts">
+import type { Recommendation } from '../data'
+
+defineProps<{ item: Recommendation }>()
+</script>
+
+<template>
+  <button
+    type="button"
+    class="group overflow-hidden border border-[#704033] bg-[#351817] text-left transition hover:-translate-y-0.5 hover:border-[#d0734e] hover:bg-[#4a211d]"
+  >
+    <span class="relative block h-24 overflow-hidden" :class="item.headerBgClass">
+      <img
+        :alt="item.name"
+        class="h-full w-full object-cover opacity-75 transition duration-300 group-hover:scale-105 group-hover:opacity-100"
+        :src="item.image"
+      />
+      <span
+        class="absolute inset-0 grid place-items-center bg-[#351817]/0 text-[#fff0bd] opacity-0 transition group-hover:bg-[#351817]/55 group-hover:opacity-100"
+      >
+        <Icon icon="solar:eye-linear" width="20" />
+      </span>
+    </span>
+    <span class="block p-3">
+      <span class="flex items-start justify-between gap-2">
+        <b class="text-[#f3dba1]">{{ item.name }}</b>
+        <Icon class="text-[#e3b85c]" :icon="item.icon" width="17" />
+      </span>
+      <span class="mt-1 block text-[11px] text-[#d8b45e]">{{ item.subtitle }}</span>
+      <span class="mt-2 block text-xs leading-5 text-[#c6a18c]">{{ item.desc }}</span>
+    </span>
+  </button>
+</template>

+ 25 - 0
src/views/culture-resources/components/RecommendationSection.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts">
+import type { Recommendation } from '../data'
+import RecommendationCard from './RecommendationCard.vue'
+
+defineProps<{
+  subtitle: string
+  items: Recommendation[]
+  columns: number
+}>()
+</script>
+
+<template>
+  <div class="mt-4 border-t border-[#84503a]/70 pt-3">
+    <div class="flex items-center justify-between">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#e3b85c]" icon="solar:stars-minimalistic-linear" width="19" />
+        <h4 class="text-sm font-bold text-[#f1d99d]">精选推荐</h4>
+      </div>
+      <span class="text-[10px] text-[#c89d78]">{{ subtitle }}</span>
+    </div>
+    <div class="mt-3 grid gap-3" :class="columns === 3 ? 'grid-cols-3' : 'grid-cols-2'">
+      <RecommendationCard v-for="item in items" :key="item.name" :item="item" />
+    </div>
+  </div>
+</template>

+ 72 - 0
src/views/culture-resources/components/ResourceOverviewPanel.vue

@@ -0,0 +1,72 @@
+<script setup lang="ts">
+import { overviewStats, dataUpdateStatus, overviewUpdates } from '../data'
+</script>
+
+<template>
+  <article
+    class="border border-[#7f4034] bg-[#351817]/80 p-5 shadow-[inset_0_0_22px_rgba(194,77,54,.12)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">资源全景概览</h2>
+      </div>
+      <span
+        class="grid h-10 w-10 place-items-center rounded-sm border border-[#af7445] bg-[#5d3028] text-[#f1cf85]"
+      >
+        <Icon icon="solar:library-linear" width="23" />
+      </span>
+    </div>
+
+    <div class="mt-5 grid grid-cols-2 gap-3">
+      <div
+        v-for="stat in overviewStats"
+        :key="stat.label"
+        class="border-l-2 bg-[#2b1514]/70 p-3"
+        :class="stat.accentClass"
+      >
+        <p class="text-xs text-[#c6a18c]">{{ stat.label }}</p>
+        <p class="mt-1 text-2xl font-bold text-[#fae8b1]">
+          {{ stat.value }}<span class="ml-1 text-xs font-normal">{{ stat.unit }}</span>
+        </p>
+      </div>
+    </div>
+
+    <div class="mt-5 border-t border-[#7f4034]/60 pt-4 text-xs text-[#c9a58c]">
+      <p class="flex items-center justify-between">
+        <span>数据更新状态</span>
+        <span class="flex items-center gap-1 text-[#e1ad62]">
+          <i class="pulse-dot h-2 w-2 rounded-full bg-[#e4674d]"></i>{{ dataUpdateStatus.status }}
+        </span>
+      </p>
+      <ProgressBar
+        class="mt-3"
+        :percent="dataUpdateStatus.progress"
+        height="h-1.5"
+        track-class="bg-[#5b2925]"
+        bar-class="bg-gradient-to-r from-[#b94d3a] to-[#e1b65b]"
+      />
+      <p class="mt-2 text-[11px] text-[#b99380]">{{ dataUpdateStatus.desc }}</p>
+    </div>
+
+    <div class="mt-4 border-t border-[#7f4034]/60 pt-4">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#df925f]" icon="solar:calendar-mark-linear" width="18" />
+        <h3 class="text-sm font-bold text-[#f5df9e]">近期资源更新</h3>
+      </div>
+      <div class="mt-3 space-y-3 text-xs">
+        <div
+          v-for="update in overviewUpdates"
+          :key="update.title"
+          class="flex justify-between border-l-2 pl-3"
+          :class="update.accentClass"
+        >
+          <div>
+            <b>{{ update.title }}</b>
+            <p class="mt-1 text-[#b99380]">{{ update.category }}</p>
+          </div>
+          <span :class="update.timeClass">{{ update.time }}</span>
+        </div>
+      </div>
+    </div>
+  </article>
+</template>

+ 84 - 0
src/views/culture-resources/components/ResourceSystemPanel.vue

@@ -0,0 +1,84 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { resourceModules, spaceRecommendations, archiveRecommendations } from '../data'
+import HeritageList from './HeritageList.vue'
+import TalentList from './TalentList.vue'
+import RecommendationSection from './RecommendationSection.vue'
+
+// 当前选中的资源体系标签
+const activeKey = ref('heritage')
+
+const activeModule = computed(
+  () => resourceModules.find((m) => m.key === activeKey.value) ?? resourceModules[0]!,
+)
+</script>
+
+<template>
+  <article
+    class="border border-[#7f4034] bg-[#351817]/80 p-5 shadow-[inset_0_0_22px_rgba(194,77,54,.12)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">六大资源体系</h2>
+      </div>
+      <p class="text-xs text-[#bf9680]">点击查看所属资源目录</p>
+    </div>
+
+    <div class="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
+      <button
+        v-for="m in resourceModules"
+        :key="m.key"
+        type="button"
+        class="rounded-sm border px-2 py-2.5 text-center text-xs transition"
+        :class="
+          m.key === activeKey
+            ? 'border-[#d0734e] bg-[#7d352c] text-[#ffe2a3]'
+            : 'border-[#6e4536] bg-[#2b1514] text-[#d7b59c] hover:border-[#d0734e]'
+        "
+        @click="activeKey = m.key"
+      >
+        {{ m.label }}
+      </button>
+    </div>
+
+    <div class="mt-4 border border-[#84503a] bg-[#2a1515]/65 p-4">
+      <div class="flex items-start gap-4">
+        <span
+          class="grid h-12 w-12 shrink-0 place-items-center rounded-sm border border-[#b87749] bg-[#65352a] text-[#f2d488]"
+        >
+          <Icon :icon="activeModule.icon" width="27" />
+        </span>
+        <div>
+          <h3 class="text-lg font-bold text-[#f6dfa8]">{{ activeModule.title }}</h3>
+          <p class="mt-1 text-xs text-[#c6a18c]">{{ activeModule.desc }}</p>
+        </div>
+      </div>
+
+      <div class="mt-4 grid grid-cols-3 gap-3">
+        <div
+          v-for="(item, i) in activeModule.items"
+          :key="item"
+          class="flex items-center gap-2 border-l-2 border-[#bd654a] bg-[#351817] px-3 py-2.5 text-xs text-[#e2c6a9]"
+        >
+          <span class="text-[#e3b85c]">{{ String(i + 1).padStart(2, '0') }}</span>
+          <span>{{ item }}</span>
+        </div>
+      </div>
+
+      <RecommendationSection
+        v-if="activeKey === 'space'"
+        subtitle="传统村落 · 重点保护场所"
+        :items="spaceRecommendations"
+        :columns="2"
+      />
+      <RecommendationSection
+        v-else-if="activeKey === 'archive'"
+        subtitle="老字号 · 民俗 · 方言"
+        :items="archiveRecommendations"
+        :columns="3"
+      />
+      <HeritageList v-else-if="activeKey === 'heritage'" />
+      <TalentList v-else-if="activeKey === 'talent'" />
+    </div>
+  </article>
+</template>

+ 114 - 0
src/views/culture-resources/components/TalentList.vue

@@ -0,0 +1,114 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { talentEntries, levels, districts, type TalentEntry } from '../data'
+
+// 筛选条件
+const level = ref('all')
+const district = ref('all')
+
+const filtered = computed(() =>
+  talentEntries.filter((e) => {
+    const matchLevel = level.value === 'all' || e.level === level.value
+    const matchDistrict = district.value === 'all' || e.district === district.value
+    return matchLevel && matchDistrict
+  }),
+)
+
+// 点击条目反馈
+const feedback = ref('')
+
+function onEntryClick(entry: TalentEntry) {
+  feedback.value = `已定位${entry.name}的${entry.project}传承档案,可继续查阅传习动态。`
+}
+
+// 级别徽标样式
+function levelBadgeClass(level: string) {
+  if (level === '国家级') return 'border-[#c98f54] text-[#f0cf80]'
+  if (level === '省级' || level === '未定级') return 'border-[#8c7050] text-[#dfbf88]'
+  return 'border-[#b87749] text-[#f0cf80]'
+}
+
+// 状态色调
+function statusTextClass(tone: TalentEntry['tone']) {
+  return tone === 'active' ? 'text-[#e38b61]' : 'text-[#e3b85c]'
+}
+function statusDotClass(tone: TalentEntry['tone']) {
+  return tone === 'active' ? 'bg-[#df6e4e]' : 'bg-[#d9b257]'
+}
+</script>
+
+<template>
+  <div class="mt-4 border-t border-[#84503a]/70 pt-3">
+    <div class="flex items-center justify-between">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#e3b85c]" icon="solar:user-id-linear" width="19" />
+        <h4 class="text-sm font-bold text-[#f1d99d]">非遗传承人</h4>
+      </div>
+      <span class="text-[10px] text-[#c89d78]">人才档案 · {{ filtered.length }} 位</span>
+    </div>
+
+    <div class="mt-3 grid grid-cols-2 gap-2 border border-[#704033] bg-[#2b1514]/75 p-2 text-[11px]">
+      <label class="text-[#c6a18c]">
+        级别
+        <select
+          v-model="level"
+          class="mt-1 w-full border border-[#82503b] bg-[#351817] px-2 py-1.5 text-[#f0d7a2] outline-none focus:border-[#d0734e]"
+        >
+          <option value="all">全部级别</option>
+          <option v-for="l in levels" :key="l" :value="l">{{ l }}</option>
+        </select>
+      </label>
+      <label class="text-[#c6a18c]">
+        区县
+        <select
+          v-model="district"
+          class="mt-1 w-full border border-[#82503b] bg-[#351817] px-2 py-1.5 text-[#f0d7a2] outline-none focus:border-[#d0734e]"
+        >
+          <option value="all">全部区县</option>
+          <option v-for="d in districts" :key="d" :value="d">{{ d }}</option>
+        </select>
+      </label>
+    </div>
+
+    <div class="mt-3 grid grid-cols-2 gap-2 text-xs">
+      <button
+        v-for="e in filtered"
+        :key="e.name"
+        type="button"
+        class="flex items-start gap-3 border border-[#704033] bg-[#351817] p-3 text-left transition hover:-translate-y-0.5 hover:border-[#d0734e] hover:bg-[#4a211d]"
+        @click="onEntryClick(e)"
+      >
+        <span
+          class="grid h-9 w-9 shrink-0 place-items-center rounded-sm border border-[#b87749] bg-[#65352a] text-[#f2d488]"
+        >
+          <Icon :icon="e.icon" width="19" />
+        </span>
+        <span class="min-w-0">
+          <span class="flex items-center justify-between gap-2">
+            <b class="text-[#f3dba1]">{{ e.name }}</b>
+            <span class="border px-1.5 py-0.5 text-[10px]" :class="levelBadgeClass(e.level)">
+              {{ e.level }}
+            </span>
+          </span>
+          <span class="mt-1 block text-[#c6a18c]">{{ e.project }} · {{ e.district }}</span>
+          <span class="mt-1 flex items-center gap-1" :class="statusTextClass(e.tone)">
+            <i class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(e.tone)"></i>{{ e.status }}
+          </span>
+        </span>
+      </button>
+    </div>
+
+    <p
+      v-if="filtered.length === 0"
+      class="mt-3 border-l-2 border-[#d0734e] bg-[#4a211d] px-3 py-2 text-xs text-[#f0d7a2]"
+    >
+      未找到符合当前级别与区县筛选条件的非遗传承人。
+    </p>
+    <p
+      v-if="feedback"
+      class="mt-3 border-l-2 border-[#d0734e] bg-[#4a211d] px-3 py-2 text-xs text-[#f0d7a2]"
+    >
+      {{ feedback }}
+    </p>
+  </div>
+</template>

+ 368 - 0
src/views/culture-resources/data.ts

@@ -0,0 +1,368 @@
+/**
+ * 文化资源页静态数据
+ *
+ * 说明:以下均为按设计图编写的静态示例数据,用于前端页面搭建。
+ * 后续对接后端时,请将各 `export const` 替换为接口返回的数据,
+ * 类型定义(interface)可保持不变,作为接口数据结构的约定。
+ */
+
+// ============================== 资源全景概览 ==============================
+export interface OverviewStat {
+  label: string
+  value: string
+  unit: string
+  /** 左侧强调条颜色(tailwind 类) */
+  accentClass: string
+}
+
+export const overviewStats: OverviewStat[] = [
+  { label: '非遗代表性项目', value: '368', unit: '项', accentClass: 'border-l-[#d66b4d]' },
+  { label: '非遗代表性传承人', value: '286', unit: '位', accentClass: 'border-l-[#d9b257]' },
+  { label: '文化空间', value: '1,560', unit: '处', accentClass: 'border-l-[#c8724e]' },
+  { label: '档案成果', value: '32.45', unit: '万条', accentClass: 'border-l-[#b78756]' },
+]
+
+/** 数据更新状态 */
+export const dataUpdateStatus = {
+  /** 标准化整理进度(百分比) */
+  progress: 88,
+  status: '实时汇聚',
+  desc: '已完成 88% 核心资源数据标准化整理',
+}
+
+export interface OverviewUpdate {
+  title: string
+  category: string
+  time: string
+  /** 时间标签颜色 */
+  timeClass: string
+  /** 左侧强调条颜色 */
+  accentClass: string
+}
+
+export const overviewUpdates: OverviewUpdate[] = [
+  {
+    title: '水仙花雕刻技艺影像补录',
+    category: '数字档案资源档案',
+    time: '今日',
+    timeClass: 'text-[#e6a276]',
+    accentClass: 'border-l-[#d66b4d]',
+  },
+  {
+    title: '云霄县传统村落名录核验',
+    category: '历史街区、传统村落名录',
+    time: '昨日',
+    timeClass: 'text-[#e0b967]',
+    accentClass: 'border-l-[#d9b257]',
+  },
+]
+
+// ============================== 六大资源体系 ==============================
+export interface ResourceModule {
+  key: string
+  /** 标签页文案 */
+  label: string
+  /** 面板标题 */
+  title: string
+  desc: string
+  icon: string
+  /** 面板条目列表 */
+  items: string[]
+}
+
+export const resourceModules: ResourceModule[] = [
+  {
+    key: 'heritage',
+    label: '非遗代表性项目',
+    title: '非遗项目资源',
+    desc: '系统梳理各级各类非遗项目的基础档案、数字档案与存续状态。',
+    icon: 'solar:medal-ribbons-star-linear',
+    items: ['各级各类非遗项目档案', '数字档案资源档案', '项目存续状态分布'],
+  },
+  {
+    key: 'talent',
+    label: '非遗代表性传承人',
+    title: '传承人才队伍',
+    desc: '建立覆盖各层级传承人的人才档案、传承团体与保护机构信息。',
+    icon: 'solar:user-id-linear',
+    items: ['各级传承人档案', '传承团体/群体数量', '非遗保护单位/传习中心'],
+  },
+  {
+    key: 'space',
+    label: '文化空间保护',
+    title: '文化空间保护',
+    desc: '完整记录文物古迹、传统村落与文化生态空间的保护信息。',
+    icon: 'solar:buildings-2-linear',
+    items: [
+      '文物古迹 · 文物名单',
+      '文物古迹 · 其他各类文化遗产名单',
+      '文物古迹 · 自然生态环境',
+      '文物古迹 · 城乡风貌',
+      '传统村落名录',
+      '非遗特色村镇、社区',
+      '“非遗在社区”活动清单',
+      '非遗工坊',
+      '老字号',
+    ],
+  },
+  {
+    key: 'archive',
+    label: '闽南文化档案',
+    title: '闽南文化档案',
+    desc: '沉淀民俗、方言、老字号与历史人文资料,留存闽南文化记忆。',
+    icon: 'solar:folder-with-files-linear',
+    items: ['民俗、方言、老字号资源资料', '历史人文资料'],
+  },
+  {
+    key: 'research',
+    label: '理论研究',
+    title: '理论研究',
+    desc: '汇集文化生态保护相关研究力量与阶段性学术成果。',
+    icon: 'solar:book-bookmark-linear',
+    items: ['研究机构', '专家学者', '研究成果'],
+  },
+  {
+    key: 'policy',
+    label: '政策法规',
+    title: '政策法规',
+    desc: '提供文化资源保护与传承工作的政策法规入口。',
+    icon: 'solar:document-text-linear',
+    items: ['政策法规入口'],
+  },
+]
+
+// ============================== 精选推荐 ==============================
+export interface Recommendation {
+  name: string
+  icon: string
+  subtitle: string
+  desc: string
+  image: string
+  headerBgClass: string
+}
+
+export const spaceRecommendations: Recommendation[] = [
+  {
+    name: '田螺坑土楼群',
+    icon: 'solar:home-2-linear',
+    subtitle: '传统村落 · 南靖县书洋镇',
+    desc: '持续开展土楼建筑本体巡查与聚落风貌协同保护。',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/c53c5c35918d4cd88b31ca68702c240c.jpg',
+    headerBgClass: 'bg-[#6b4f2c]',
+  },
+  {
+    name: '漳州古城历史文化街区',
+    icon: 'solar:buildings-2-linear',
+    subtitle: '重点保护场所 · 芗城区',
+    desc: '完成骑楼街巷建档,推进传统风貌修缮与活化利用。',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/931b7233015d495e929b8fe3250bfe8c.jpg',
+    headerBgClass: 'bg-[#6d4d2b]',
+  },
+  {
+    name: '河坑土楼群',
+    icon: 'solar:map-point-linear',
+    subtitle: '传统村落 · 南靖县梅林镇',
+    desc: '以土楼群落整体保护为重点,完善日常监测与修缮台账。',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/46df763875cd43f288877e38ec0fd319.jpg',
+    headerBgClass: 'bg-[#65352a]',
+  },
+  {
+    name: '东山关帝庙',
+    icon: 'solar:buildings-3-linear',
+    subtitle: '重点保护场所 · 东山县铜陵镇',
+    desc: '围绕古建筑构件与周边环境实施常态化保护监测。',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/ef2debb230de4a0887750ca1f6116274.jpg',
+    headerBgClass: 'bg-[#71362e]',
+  },
+]
+
+export const archiveRecommendations: Recommendation[] = [
+  {
+    name: '八宝印泥老字号档案',
+    icon: 'solar:pen-new-square-linear',
+    subtitle: '老字号档案 · 芗城区',
+    desc: '收录印泥配方、商号沿革与传统制作技艺口述记录。',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/d32e8ca501f74560b45f58f22388852e.jpg',
+    headerBgClass: 'bg-[#65352a]',
+  },
+  {
+    name: '漳州元宵民俗档案',
+    icon: 'solar:confetti-minimalistic-linear',
+    subtitle: '民俗档案 · 芗城区',
+    desc: '整理迎灯、游灯与家族祭祀的田野影像及访谈资料。',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/932c7ab061d045578f20f63e0defc2f5.jpg',
+    headerBgClass: 'bg-[#71362e]',
+  },
+  {
+    name: '漳州闽南方言语料档案',
+    icon: 'solar:translation-2-linear',
+    subtitle: '方言档案 · 漳浦县、龙海区',
+    desc: '保存日常词汇、谚语与发音样本,支持方言传承研究。',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/e47171795e86427b95ae89afba24f5e8.jpg',
+    headerBgClass: 'bg-[#6b4f2c]',
+  },
+]
+
+// ============================== 筛选选项 ==============================
+/** 非遗十大门类 */
+export const heritageCategories = [
+  '民间文学',
+  '传统音乐',
+  '传统舞蹈',
+  '传统戏剧',
+  '曲艺',
+  '传统体育、游艺与杂技',
+  '传统美术',
+  '传统技艺',
+  '传统医药',
+  '民俗',
+]
+
+/** 保护级别 */
+export const levels = ['国家级', '省级', '市级', '区县级', '未定级']
+
+/** 漳州区县 */
+export const districts = [
+  '芗城区',
+  '龙文区',
+  '龙海区',
+  '长泰区',
+  '漳浦县',
+  '云霄县',
+  '诏安县',
+  '东山县',
+  '平和县',
+  '南靖县',
+  '华安县',
+]
+
+// ============================== 重点非遗项目 ==============================
+export interface HeritageEntry {
+  name: string
+  category: string
+  level: string
+  districts: string[]
+  icon: string
+  image: string
+  note: string
+}
+
+export const heritageEntries: HeritageEntry[] = [
+  {
+    name: '歌仔戏',
+    category: '传统戏剧',
+    level: '国家级',
+    districts: ['芗城区', '龙海区'],
+    icon: 'solar:clapperboard-linear',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/fec813433056449f94fa9a0c6344ce11.jpg',
+    note: '活态传承',
+  },
+  {
+    name: '布袋木偶戏',
+    category: '传统戏剧',
+    level: '国家级',
+    districts: ['龙海区'],
+    icon: 'solar:mask-happy-linear',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/0773ee241fb44a1a99c2e07f39566db4.jpg',
+    note: '常态展演',
+  },
+  {
+    name: '漳州木版年画',
+    category: '传统美术',
+    level: '国家级',
+    districts: ['芗城区'],
+    icon: 'solar:pallete-2-linear',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/a1eb77553860452b88762b67954a1bec.jpg',
+    note: '数字采集完成',
+  },
+  {
+    name: '八宝印泥制作技艺',
+    category: '传统技艺',
+    level: '国家级',
+    districts: ['芗城区'],
+    icon: 'solar:pen-new-square-linear',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/781e0f6f33674d8bbd86b466c2228bed.jpg',
+    note: '传习有序',
+  },
+  {
+    name: '漳浦剪纸',
+    category: '传统美术',
+    level: '国家级',
+    districts: ['漳浦县'],
+    icon: 'solar:scissors-linear',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/e3311bbccfe74e239b157af6eb9551c4.jpg',
+    note: '传承人建档',
+  },
+  {
+    name: '东山歌册',
+    category: '民间文学',
+    level: '省级',
+    districts: ['东山县'],
+    icon: 'solar:music-note-4-linear',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/2a7798eae2754c6e87282c5677fc0aa8.jpg',
+    note: '田野整理中',
+  },
+]
+
+// ============================== 非遗传承人 ==============================
+export interface TalentEntry {
+  name: string
+  level: string
+  district: string
+  project: string
+  icon: string
+  status: string
+  /** 状态色调:active 红色 / sorting 金色 */
+  tone: 'active' | 'sorting'
+}
+
+export const talentEntries: TalentEntry[] = [
+  { name: '陈锦章', level: '国家级', district: '芗城区', project: '歌仔戏', icon: 'solar:microphone-3-linear', status: '传习授课中', tone: 'active' },
+  { name: '杨亚东', level: '国家级', district: '龙海区', project: '布袋木偶戏', icon: 'solar:mask-happy-linear', status: '常态展演', tone: 'active' },
+  { name: '黄素莲', level: '市级', district: '芗城区', project: '漳州木版年画', icon: 'solar:pallete-2-linear', status: '数字传习中', tone: 'active' },
+  { name: '蔡志斌', level: '未定级', district: '芗城区', project: '八宝印泥制作技艺', icon: 'solar:pen-new-square-linear', status: '在库传承', tone: 'active' },
+  { name: '林燕玉', level: '区县级', district: '漳浦县', project: '漳浦剪纸', icon: 'solar:scissors-linear', status: '带徒培养中', tone: 'active' },
+  { name: '吴志强', level: '省级', district: '东山县', project: '东山歌册', icon: 'solar:music-note-4-linear', status: '田野整理中', tone: 'sorting' },
+  { name: '陈秀琴', level: '市级', district: '龙文区', project: '龙文南音', icon: 'solar:music-note-4-linear', status: '传习授课中', tone: 'active' },
+  { name: '林志明', level: '区县级', district: '长泰区', project: '长泰拍胸舞', icon: 'solar:running-2-linear', status: '展演传承中', tone: 'active' },
+  { name: '方淑华', level: '省级', district: '云霄县', project: '云霄锦歌', icon: 'solar:microphone-3-linear', status: '田野整理中', tone: 'sorting' },
+  { name: '沈文海', level: '市级', district: '诏安县', project: '诏安九鲤舞', icon: 'solar:medal-ribbons-star-linear', status: '常态展演', tone: 'active' },
+  { name: '张春兰', level: '区县级', district: '平和县', project: '平和青草药', icon: 'solar:health-linear', status: '诊疗传习中', tone: 'active' },
+  { name: '何国华', level: '省级', district: '南靖县', project: '南靖土楼营造习俗', icon: 'solar:home-2-linear', status: '社区传承中', tone: 'sorting' },
+  { name: '李金水', level: '市级', district: '华安县', project: '华安玉雕', icon: 'solar:star-shine-linear', status: '工艺传习中', tone: 'active' },
+]
+
+// ============================== 文化影像 ==============================
+export interface GalleryItem {
+  title: string
+  image: string
+  imageAlt: string
+  headerBgClass: string
+}
+
+export const galleryItems: GalleryItem[] = [
+  {
+    title: '古城骑楼建档',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/fe29e1eeb24946e099ee2869b27ace67.jpg',
+    imageAlt: 'Zhangzhou ancient city red brick arcade archive',
+    headerBgClass: 'bg-[#6d4d2b]',
+  },
+  {
+    title: '非遗传习现场',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/ae61812f573a4adf9ffe1c51e614b0fd.jpg',
+    imageAlt: 'Traditional heritage teaching session in Minnan',
+    headerBgClass: 'bg-[#65352a]',
+  },
+  {
+    title: '传统村落巡礼',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/0bf834ffb6714fd5bfb27687b58d5bba.jpg',
+    imageAlt: 'Traditional village courtyard in southern Fujian',
+    headerBgClass: 'bg-[#6b4f2c]',
+  },
+  {
+    title: '民俗档案辑录',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/5d95cbc5efaf4894b43b307224ec9f11.jpg',
+    imageAlt: 'Minnan cultural archive documents and folk records',
+    headerBgClass: 'bg-[#71362e]',
+  },
+]

+ 44 - 0
src/views/culture-tourism/CultureTourismView.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import DashboardLayout from '@/components/DashboardLayout.vue'
+
+import TourismModulePanel from './components/TourismModulePanel.vue'
+import { tourismModules } from './data'
+
+// 当前选中的模块(文旅资源 / 文创产品)
+const activeKey = ref('resources')
+
+const activeModule = computed(
+  () => tourismModules.find((m) => m.key === activeKey.value) ?? tourismModules[0]!,
+)
+</script>
+
+<template>
+  <DashboardLayout footer-middle="数据来源:市级文旅融合资源库 · 实时汇聚">
+    <section class="mt-5 flex items-end justify-between">
+      <div>
+        <h1 class="mt-2 text-[30px] font-bold tracking-[.16em] text-[#fff0bd]">文旅融合</h1>
+      </div>
+      <div class="flex gap-2 pb-1 text-xs">
+        <button
+          v-for="m in tourismModules"
+          :key="m.key"
+          type="button"
+          class="rounded-sm border px-4 py-2 text-xs"
+          :class="
+            activeKey === m.key
+              ? 'border-[#d1734e] bg-[#84352d] text-[#ffe1a0]'
+              : 'border-[#79503c] bg-[#351817]/80 text-[#d6b49a]'
+          "
+          @click="activeKey = m.key"
+        >
+          {{ m.label }}
+        </button>
+      </div>
+    </section>
+
+    <section class="mt-5 grid grid-cols-12 gap-4">
+      <TourismModulePanel :module="activeModule" />
+    </section>
+  </DashboardLayout>
+</template>

+ 62 - 0
src/views/culture-tourism/components/TourismModulePanel.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import type { TourismModule } from '../data'
+
+defineProps<{ module: TourismModule }>()
+</script>
+
+<template>
+  <article
+    class="col-span-12 border border-[#7f4034] bg-[#351817]/80 p-5 shadow-[inset_0_0_22px_rgba(194,77,54,.12)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <p class="text-[10px] tracking-[.2em] text-[#d8b45e]">{{ module.kicker }}</p>
+        <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">{{ module.title }}</h2>
+        <p class="mt-1 text-xs text-[#bf9680]">{{ module.desc }}</p>
+      </div>
+    </div>
+
+    <div class="mt-4 grid grid-cols-1 gap-4 text-xs xl:grid-cols-2">
+      <article
+        v-for="(card, i) in module.cards"
+        :key="card.title"
+        class="group border border-[#704033] bg-[#2b1514]/80 p-3 transition duration-300 hover:-translate-y-1 hover:border-[#d1734e] hover:bg-[#3a1c18]"
+      >
+        <div
+          class="relative aspect-[16/9] scale-x-[0.9] overflow-hidden border border-[#8e4c36] bg-gradient-to-br from-[#7c3f30] via-[#4b211e] to-[#b27546]"
+        >
+          <img
+            v-if="card.image"
+            :alt="card.imageAlt"
+            class="h-full w-full object-cover object-center transition duration-500 group-hover:scale-110 group-hover:brightness-125"
+            :src="card.image"
+          />
+          <div
+            class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-[#24100f] via-[#24100f]/80 to-transparent px-2 pb-2 pt-6"
+          >
+            <span class="block text-[10px] tracking-[.12em] text-[#e9b86e]">
+              文旅影像 {{ String(i + 1).padStart(2, '0') }}
+            </span>
+            <span
+              class="mt-1 block text-[10px] text-[#f5df9e] opacity-0 transition duration-300 group-hover:opacity-100"
+            >
+              {{ card.title }} · 悬停查看
+            </span>
+          </div>
+        </div>
+
+        <div class="mt-3 flex items-center justify-between">
+          <p class="text-[#d9b257]">{{ String(i + 1).padStart(2, '0') }}</p>
+          <span
+            class="grid h-7 w-7 place-items-center border border-[#af7445] bg-[#5d3028] text-[#f1cf85]"
+          >
+            <Icon :icon="card.icon" width="16" />
+          </span>
+        </div>
+
+        <h3 class="mt-1 font-bold text-[#f6dfa8]">{{ card.title }}</h3>
+        <p class="mt-2 leading-5 text-[#b99380]">{{ card.desc }}</p>
+      </article>
+    </div>
+  </article>
+</template>

+ 78 - 0
src/views/culture-tourism/data.ts

@@ -0,0 +1,78 @@
+/**
+ * 文旅融合页静态数据
+ *
+ * 说明:以下均为按设计图编写的静态示例数据,用于前端页面搭建。
+ * 后续对接后端时,请将各 `export const` 替换为接口返回的数据,
+ * 类型定义(interface)可保持不变,作为接口数据结构的约定。
+ *
+ * 注:设计稿脚本中另预留了「老字号(TIME-HONORED BRANDS)」模块数据,
+ *     但页面未提供对应入口,故此处未收录。
+ */
+
+export interface TourismCard {
+  icon: string
+  title: string
+  desc: string
+  /** 图片替代文本 */
+  imageAlt: string
+  /** 配图地址;为空时仅显示渐变占位背景 */
+  image?: string
+}
+
+export interface TourismModule {
+  key: string
+  /** 标签页文案 */
+  label: string
+  /** 标题上方小字(英文) */
+  kicker: string
+  title: string
+  desc: string
+  cards: TourismCard[]
+}
+
+export const tourismModules: TourismModule[] = [
+  {
+    key: 'resources',
+    label: '文旅资源',
+    kicker: 'CULTURAL RESOURCES',
+    title: '文旅资源',
+    desc: '以主题线路和非遗空间为载体,连接文化保护、在地体验与城市生活。',
+    cards: [
+      {
+        icon: 'solar:route-linear',
+        title: '闽南文化主题旅游线路',
+        desc: '古城、土楼与滨海文化串联成线,呈现闽南建筑、音乐与信俗的深层记忆。',
+        imageAlt: 'Minnan cultural tourism route',
+        image: 'https://modao.cc/agent-py/media/user_assets/2026-08-20/af55ba39bd8a4cf386bb62f595bc7f95.png',
+      },
+      {
+        icon: 'solar:buildings-2-linear',
+        title: '非遗景区・街区・民宿',
+        desc: '以非遗工坊、特色街区与文化民宿承载活态传承,让传统技艺融入旅居日常。',
+        imageAlt: 'Intangible heritage district and homestay',
+        image: 'https://modao.cc/agent-py/media/user_assets/2026-08-20/fa659eeaa0af4d088502359d37fc49bd.png',
+      },
+    ],
+  },
+  {
+    key: 'products',
+    label: '文创产品',
+    kicker: 'CULTURAL PRODUCTS',
+    title: '文创产品',
+    desc: '从传统纹样、工艺与地方物产中提炼设计灵感,让闽南文化可带走、可使用。',
+    cards: [
+      {
+        icon: 'solar:pen-new-square-linear',
+        title: '八宝印泥礼盒',
+        desc: '老字号印泥技艺结合当代礼赠设计,传递一方朱砂匠心。',
+        imageAlt: 'Babao seal paste craft gift box',
+      },
+      {
+        icon: 'solar:pallete-2-linear',
+        title: '木版年画拓印套装',
+        desc: '精选传统纹样开发拓印、书签与家居摆件,延续新年祝福。',
+        imageAlt: 'Minnan woodblock print product',
+      },
+    ],
+  },
+]

+ 44 - 0
src/views/digital-archives/DigitalArchivesView.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import DashboardLayout from '@/components/DashboardLayout.vue'
+
+import ArchiveFilterPanel from './components/ArchiveFilterPanel.vue'
+import ArchiveDirectoryPanel from './components/ArchiveDirectoryPanel.vue'
+import { archiveStats, type ArchiveFilter } from './data'
+
+// 当前选中的档案分类
+const activeFilter = ref<ArchiveFilter['key']>('all')
+</script>
+
+<template>
+  <DashboardLayout>
+    <section class="mt-5 flex items-end justify-between">
+      <div>
+        <h1 class="mt-2 text-[30px] font-bold tracking-[.16em] text-[#fff0bd]">
+          闽南文化数字档案
+        </h1>
+      </div>
+    </section>
+
+    <section class="mt-5 grid grid-cols-3 gap-4">
+      <article
+        v-for="stat in archiveStats"
+        :key="stat.label"
+        class="border border-[#7f4034] bg-[#351817]/80 p-4 shadow-[inset_0_0_22px_rgba(194,77,54,.12)]"
+      >
+        <p class="flex items-center gap-2 text-xs text-[#c6a18c]">
+          <Icon :class="stat.iconClass" :icon="stat.icon" width="18" />
+          {{ stat.label }}
+        </p>
+        <p class="mt-3 text-3xl font-bold text-[#fae8b1]">
+          {{ stat.value }}<span class="ml-1 text-xs font-normal">{{ stat.unit }}</span>
+        </p>
+      </article>
+    </section>
+
+    <section class="mt-4 grid grid-cols-[280px_minmax(0,1fr)] gap-4">
+      <ArchiveFilterPanel v-model:active-filter="activeFilter" />
+      <ArchiveDirectoryPanel :active-filter="activeFilter" />
+    </section>
+  </DashboardLayout>
+</template>

+ 51 - 0
src/views/digital-archives/components/ArchiveDirectoryPanel.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import { archiveCards, directoryTitles, type ArchiveFilter } from '../data'
+
+const props = defineProps<{ activeFilter: ArchiveFilter['key'] }>()
+
+const filtered = computed(() =>
+  props.activeFilter === 'all'
+    ? archiveCards
+    : archiveCards.filter((c) => c.category === props.activeFilter),
+)
+
+const title = computed(() => directoryTitles[props.activeFilter])
+</script>
+
+<template>
+  <section class="border border-[#7f4034] bg-[#351817]/80 p-5">
+    <div class="flex items-start justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">{{ title }}</h2>
+      </div>
+      <div class="flex items-center gap-2 text-xs text-[#d6b49a]">
+        <Icon class="text-[#d9b257]" icon="solar:sort-alphabetically-linear" width="18" />
+        按入库时间排序
+      </div>
+    </div>
+
+    <div class="mt-4 grid grid-cols-2 gap-3">
+      <article
+        v-for="card in filtered"
+        :key="card.title"
+        class="border-l-2 bg-[#2b1514]/75 p-4"
+        :class="card.accentClass"
+      >
+        <div class="flex justify-between">
+          <span class="text-xs" :class="card.categoryClass">{{ card.categoryLabel }}</span>
+          <span class="text-[10px] text-[#bb8e79]">{{ card.date }}</span>
+        </div>
+        <h3 class="mt-3 font-bold text-[#f3dba1]">{{ card.title }}</h3>
+        <p class="mt-2 text-xs leading-5 text-[#b99380]">{{ card.desc }}</p>
+      </article>
+    </div>
+
+    <p
+      v-if="filtered.length === 0"
+      class="mt-4 border-l-2 border-[#d0734e] bg-[#4a211d] px-3 py-2 text-xs text-[#f0d7a2]"
+    >
+      该分类暂无近期入库档案。
+    </p>
+  </section>
+</template>

+ 35 - 0
src/views/digital-archives/components/ArchiveFilterPanel.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+import { archiveFilters, type ArchiveFilter } from '../data'
+
+defineProps<{ activeFilter: ArchiveFilter['key'] }>()
+
+const emit = defineEmits<{ (e: 'update:activeFilter', value: ArchiveFilter['key']): void }>()
+</script>
+
+<template>
+  <aside class="border border-[#7f4034] bg-[#351817]/80 p-4">
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <h2 class="font-bold text-[#f5df9e]">档案分类浏览</h2>
+      <Icon class="text-[#df925f]" icon="solar:filter-linear" width="20" />
+    </div>
+
+    <div class="mt-3 space-y-1 text-sm">
+      <button
+        v-for="f in archiveFilters"
+        :key="f.key"
+        type="button"
+        class="flex w-full items-center gap-3 border-l-2 px-3 py-3 text-left transition"
+        :class="
+          activeFilter === f.key
+            ? 'border-[#d66b4d] bg-[#4a211d] text-[#ffe1a0]'
+            : 'border-transparent text-[#d9bda7] hover:bg-[#42201c]'
+        "
+        @click="emit('update:activeFilter', f.key)"
+      >
+        <Icon :icon="f.icon" width="19" />
+        <span class="flex-1">{{ f.label }}</span>
+        <b>{{ f.count }}</b>
+      </button>
+    </div>
+  </aside>
+</template>

+ 103 - 0
src/views/digital-archives/data.ts

@@ -0,0 +1,103 @@
+/**
+ * 数字档案页静态数据
+ *
+ * 说明:以下均为按设计图编写的静态示例数据,用于前端页面搭建。
+ * 后续对接后端时,请将各 `export const` 替换为接口返回的数据,
+ * 类型定义(interface)可保持不变,作为接口数据结构的约定。
+ */
+
+// ============================== 档案资源总量 ==============================
+export interface ArchiveStat {
+  label: string
+  value: string
+  unit: string
+  icon: string
+  /** 图标颜色(tailwind 类) */
+  iconClass: string
+}
+
+export const archiveStats: ArchiveStat[] = [
+  { label: '档案资源总量', value: '324,518', unit: '条', icon: 'solar:database-linear', iconClass: 'text-[#e28a60]' },
+  { label: '图文影像资料', value: '218,406', unit: '份', icon: 'solar:gallery-wide-linear', iconClass: 'text-[#e3b85c]' },
+  { label: '音视频与口述史', value: '72,684', unit: '段', icon: 'solar:video-frame-linear', iconClass: 'text-[#df925f]' },
+]
+
+// ============================== 档案分类浏览 ==============================
+export interface ArchiveFilter {
+  key: 'all' | 'heritage' | 'inheritor' | 'building' | 'folk'
+  label: string
+  count: string
+  icon: string
+}
+
+export const archiveFilters: ArchiveFilter[] = [
+  { key: 'all', label: '全部资源', count: '324518', icon: 'solar:folder-open-linear' },
+  { key: 'heritage', label: '非遗项目档案', count: '368', icon: 'solar:medal-ribbons-star-linear' },
+  { key: 'inheritor', label: '传承人档案', count: '286', icon: 'solar:user-id-linear' },
+  { key: 'building', label: '文物与传统村落', count: '1404', icon: 'solar:buildings-2-linear' },
+  { key: 'folk', label: '民俗方言与老字号', count: '942', icon: 'solar:microphone-3-linear' },
+]
+
+/** 各分类对应的目录标题 */
+export const directoryTitles: Record<ArchiveFilter['key'], string> = {
+  all: '重点档案目录',
+  heritage: '非遗项目档案',
+  inheritor: '传承人档案',
+  building: '文物与传统村落档案',
+  folk: '民俗方言与老字号资料',
+}
+
+// ============================== 重点档案目录 ==============================
+export interface ArchiveCard {
+  /** 所属分类(对应筛选 key,不含 all) */
+  category: Exclude<ArchiveFilter['key'], 'all'>
+  /** 分类标签文案,如「非遗项目 · 图文 / 视频」 */
+  categoryLabel: string
+  /** 分类标签颜色(tailwind 类) */
+  categoryClass: string
+  /** 入库时间标签 */
+  date: string
+  /** 左侧强调条颜色(tailwind 类) */
+  accentClass: string
+  title: string
+  desc: string
+}
+
+export const archiveCards: ArchiveCard[] = [
+  {
+    category: 'heritage',
+    categoryLabel: '非遗项目 · 图文 / 视频',
+    categoryClass: 'text-[#e28a60]',
+    date: '今日入库',
+    accentClass: 'border-l-[#d66b4d]',
+    title: '水仙花雕刻技艺数字档案',
+    desc: '工序影像、代表作品与传承口述完整归档,新增高清素材 126 份。',
+  },
+  {
+    category: 'inheritor',
+    categoryLabel: '传承人 · 音视频',
+    categoryClass: 'text-[#e3bd65]',
+    date: '昨日入库',
+    accentClass: 'border-l-[#d9b257]',
+    title: '歌仔戏陈派唱腔口述史',
+    desc: '收录代表性传承人访谈、唱段示范与剧目手稿释读资料。',
+  },
+  {
+    category: 'building',
+    categoryLabel: '传统村落 · 三维',
+    categoryClass: 'text-[#e28a60]',
+    date: '08-11',
+    accentClass: 'border-l-[#c8724e]',
+    title: '南靖土楼群营造档案',
+    desc: '完成田野测绘、建筑构件编号与重点土楼三维实景建模复核。',
+  },
+  {
+    category: 'folk',
+    categoryLabel: '方言民俗 · 音频',
+    categoryClass: 'text-[#e3bd65]',
+    date: '08-10',
+    accentClass: 'border-l-[#b78756]',
+    title: '漳州古城老字号记忆',
+    desc: '以店铺谱系、方言称谓与经营故事串联古城街巷生活记忆。',
+  },
+]

+ 60 - 0
src/views/heritage-experience/HeritageExperienceView.vue

@@ -0,0 +1,60 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import DashboardLayout from '@/components/DashboardLayout.vue'
+
+import ActivityOverviewPanel from './components/ActivityOverviewPanel.vue'
+import ActivityFeedPanel from './components/ActivityFeedPanel.vue'
+import CampusEducationPanel from './components/CampusEducationPanel.vue'
+import ProfessionalEducationPanel from './components/ProfessionalEducationPanel.vue'
+import TeachersPanel from './components/TeachersPanel.vue'
+import StudyRoutesPanel from './components/StudyRoutesPanel.vue'
+
+// 视图切换(活动总览 / 校园传承)—— 仅切换按钮高亮状态
+const activeView = ref<'activity' | 'school'>('activity')
+
+const viewTabs = [
+  { key: 'activity', label: '活动总览' },
+  { key: 'school', label: '校园传承' },
+] as const
+</script>
+
+<template>
+  <DashboardLayout footer-middle="数据来源:市级非遗传习与体验活动数据库 · 实时汇聚">
+    <section class="mt-5 flex items-end justify-between">
+      <div>
+        <h1 class="mt-2 text-[30px] font-bold tracking-[.16em] text-[#fff0bd]">传承体验</h1>
+      </div>
+      <div class="flex gap-2 pb-1 text-xs">
+        <button
+          v-for="tab in viewTabs"
+          :key="tab.key"
+          type="button"
+          class="rounded-sm border px-4 py-2"
+          :class="
+            activeView === tab.key
+              ? 'border-[#d1734e] bg-[#84352d] text-[#ffe1a0]'
+              : 'border-[#79503c] bg-[#351817]/80 text-[#d6b49a]'
+          "
+          @click="activeView = tab.key"
+        >
+          {{ tab.label }}
+        </button>
+      </div>
+    </section>
+
+    <section class="mt-5 grid grid-cols-12 gap-4">
+      <ActivityOverviewPanel class="col-span-4" />
+      <ActivityFeedPanel class="col-span-8" />
+    </section>
+
+    <section class="mt-4 grid grid-cols-12 items-stretch gap-4">
+      <CampusEducationPanel class="col-span-4" />
+      <ProfessionalEducationPanel class="col-span-4" />
+      <TeachersPanel class="col-span-4" />
+    </section>
+
+    <section class="mt-4 grid grid-cols-1 gap-4">
+      <StudyRoutesPanel />
+    </section>
+  </DashboardLayout>
+</template>

+ 55 - 0
src/views/heritage-experience/components/ActivityFeedPanel.vue

@@ -0,0 +1,55 @@
+<script setup lang="ts">
+import { activityItems } from '../data'
+</script>
+
+<template>
+  <article
+    class="border border-[#7f4034] bg-[#351817]/80 p-5 shadow-[inset_0_0_22px_rgba(194,77,54,.12)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">近期活动与体验动态</h2>
+      </div>
+      <span class="flex items-center gap-1 text-xs text-[#e1ad62]">
+        <i class="pulse-dot h-2 w-2 rounded-full bg-[#e4674d]"></i>持续开展
+      </span>
+    </div>
+
+    <div class="mt-4 grid grid-cols-3 gap-3 text-xs">
+      <article
+        v-for="item in activityItems"
+        :key="item.title"
+        class="border border-[#704033] bg-[#2b1514]/80 p-3"
+      >
+        <button
+          type="button"
+          class="group relative block h-20 w-full overflow-hidden border border-[#8b5a3c] text-left"
+          :class="item.headerBgClass"
+        >
+          <img
+            :alt="item.title"
+            class="h-full w-full object-cover opacity-70 transition duration-300 group-hover:scale-105 group-hover:opacity-90"
+            :src="item.image"
+          />
+          <span
+            class="absolute inset-0 grid place-items-center bg-[#351817]/0 text-[#fff0bd] opacity-0 transition group-hover:bg-[#351817]/55 group-hover:opacity-100"
+          >
+            <Icon icon="solar:eye-linear" width="20" />
+          </span>
+          <span class="absolute bottom-1 left-1 rounded bg-[#54351f]/90 px-1.5 py-0.5 text-[9px] text-[#f7dea2]">
+            {{ item.badge }}
+          </span>
+        </button>
+
+        <div class="mt-3 flex items-start justify-between">
+          <span class="rounded px-2 py-1 text-[10px]" :class="item.dateClass">{{ item.date }}</span>
+          <Icon class="text-[#e9b96a]" :icon="item.icon" width="21" />
+        </div>
+
+        <h3 class="mt-3 font-bold text-[#f6dfa8]">{{ item.title }}</h3>
+        <p class="mt-1 leading-5 text-[#b99380]">{{ item.location }}</p>
+        <p class="mt-3 border-t border-[#704033] pt-2 text-[#d5a170]">{{ item.desc }}</p>
+      </article>
+    </div>
+  </article>
+</template>

+ 49 - 0
src/views/heritage-experience/components/ActivityOverviewPanel.vue

@@ -0,0 +1,49 @@
+<script setup lang="ts">
+import { activityStats, activityPlan } from '../data'
+</script>
+
+<template>
+  <article
+    class="border border-[#7f4034] bg-[#351817]/80 p-5 shadow-[inset_0_0_22px_rgba(194,77,54,.12)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">传承活动</h2>
+      </div>
+      <span
+        class="grid h-10 w-10 place-items-center rounded-sm border border-[#af7445] bg-[#5d3028] text-[#f1cf85]"
+      >
+        <Icon icon="solar:calendar-mark-linear" width="23" />
+      </span>
+    </div>
+
+    <div class="mt-5 grid grid-cols-3 gap-2">
+      <div
+        v-for="stat in activityStats"
+        :key="stat.label"
+        class="border-l-2 bg-[#2b1514]/70 p-3"
+        :class="stat.accentClass"
+      >
+        <p class="text-[11px] text-[#c6a18c]">{{ stat.label }}</p>
+        <p class="mt-1 text-xl font-bold text-[#fae8b1]">
+          {{ stat.value }}<span class="ml-1 text-[10px] font-normal">{{ stat.unit }}</span>
+        </p>
+      </div>
+    </div>
+
+    <div class="mt-5 border-t border-[#7f4034]/60 pt-4 text-xs text-[#c9a58c]">
+      <div class="flex justify-between">
+        <span>全年传承计划完成度</span>
+        <b class="text-[#f0cf80]">{{ activityPlan.progress }}%</b>
+      </div>
+      <ProgressBar
+        class="mt-2"
+        :percent="activityPlan.progress"
+        height="h-1.5"
+        track-class="bg-[#5b2925]"
+        bar-class="bg-gradient-to-r from-[#b94d3a] to-[#e1b65b]"
+      />
+      <p class="mt-3 leading-5 text-[#b99380]">{{ activityPlan.desc }}</p>
+    </div>
+  </article>
+</template>

+ 27 - 0
src/views/heritage-experience/components/CampusEducationPanel.vue

@@ -0,0 +1,27 @@
+<script setup lang="ts">
+import { campusAchievements } from '../data'
+</script>
+
+<template>
+  <article class="border border-[#7f4034] bg-[#351817]/80 p-4">
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#df925f]" icon="solar:buildings-3-linear" width="20" />
+        <h2 class="font-bold text-[#f5df9e]">非遗进校园成果</h2>
+      </div>
+      <span class="text-[10px] text-[#d8b45e]">覆盖 52 所学校</span>
+    </div>
+
+    <div class="mt-4 grid grid-cols-2 gap-2 text-xs">
+      <div
+        v-for="item in campusAchievements"
+        :key="item.title"
+        class="border-l-2 bg-[#2b1514]/65 p-2.5"
+        :class="item.accentClass"
+      >
+        <b>{{ item.title }}</b>
+        <p class="mt-1 leading-5 text-[#b99380]">{{ item.desc }}</p>
+      </div>
+    </div>
+  </article>
+</template>

+ 31 - 0
src/views/heritage-experience/components/ProfessionalEducationPanel.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+import { educationItems } from '../data'
+</script>
+
+<template>
+  <article class="border border-[#7f4034] bg-[#351817]/80 p-4">
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#df925f]" icon="solar:book-2-linear" width="20" />
+        <h2 class="font-bold text-[#f5df9e]">专业教育</h2>
+      </div>
+      <span class="text-[10px] text-[#d8b45e]">14 门课程</span>
+    </div>
+
+    <div class="mt-4 space-y-3 text-xs">
+      <div
+        v-for="item in educationItems"
+        :key="item.title"
+        class="border-l-2 bg-[#2b1514]/65 p-3"
+        :class="item.accentClass"
+      >
+        <div v-if="item.status" class="flex items-center justify-between">
+          <b>{{ item.title }}</b>
+          <span class="text-[#e6a276]">{{ item.status }}</span>
+        </div>
+        <b v-else>{{ item.title }}</b>
+        <p class="mt-1 leading-5 text-[#b99380]">{{ item.desc }}</p>
+      </div>
+    </div>
+  </article>
+</template>

+ 27 - 0
src/views/heritage-experience/components/StudyRoutesPanel.vue

@@ -0,0 +1,27 @@
+<script setup lang="ts">
+import { studyRoutes } from '../data'
+</script>
+
+<template>
+  <article class="w-full border border-[#7f4034] bg-[#351817]/80 p-5">
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#df925f]" icon="solar:map-point-wave-linear" width="20" />
+        <h2 class="font-bold text-[#f5df9e]">闽南研学路线</h2>
+      </div>
+    </div>
+
+    <div class="mt-5 grid grid-cols-3 gap-4 text-xs">
+      <div
+        v-for="route in studyRoutes"
+        :key="route.routeNo"
+        class="border-t-2 bg-[#2b1514]/65 p-4"
+        :class="route.accentClass"
+      >
+        <p :class="route.routeNoClass">{{ route.routeNo }}</p>
+        <h3 class="mt-2 font-bold text-[#f4dfaa]">{{ route.title }}</h3>
+        <p class="mt-2 leading-5 text-[#b99380]">{{ route.desc }}</p>
+      </div>
+    </div>
+  </article>
+</template>

+ 39 - 0
src/views/heritage-experience/components/TeachersPanel.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import { teachers, teacherSummary } from '../data'
+</script>
+
+<template>
+  <article class="border border-[#7f4034] bg-[#351817]/80 p-4">
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div class="flex items-center gap-2">
+        <Icon class="text-[#df925f]" icon="solar:users-group-rounded-linear" width="20" />
+        <h2 class="font-bold text-[#f5df9e]">师资队伍</h2>
+      </div>
+      <span class="text-[10px] text-[#d8b45e]">86 人联合授课</span>
+    </div>
+
+    <div class="mt-4 space-y-3 text-xs">
+      <div
+        v-for="teacher in teachers"
+        :key="teacher.name"
+        class="flex items-center gap-3 border-b border-[#6a342d]/60 pb-3"
+      >
+        <span
+          class="grid h-9 w-9 place-items-center rounded-full border border-[#b87749] bg-[#65352a] text-[#f2d488]"
+        >
+          <Icon icon="solar:user-id-linear" width="19" />
+        </span>
+        <div class="flex-1">
+          <b>{{ teacher.name }}</b>
+          <p class="mt-1 text-[#b99380]">{{ teacher.role }}</p>
+        </div>
+        <span :class="teacher.statusClass">{{ teacher.status }}</span>
+      </div>
+
+      <div class="flex items-center justify-between border-t border-[#7f4034]/60 pt-3">
+        <span class="text-[#b99380]">{{ teacherSummary.label }}</span>
+        <span class="text-[#f0c979]">{{ teacherSummary.count }}</span>
+      </div>
+    </div>
+  </article>
+</template>

+ 170 - 0
src/views/heritage-experience/data.ts

@@ -0,0 +1,170 @@
+/**
+ * 传承体验页静态数据
+ *
+ * 说明:以下均为按设计图编写的静态示例数据,用于前端页面搭建。
+ * 后续对接后端时,请将各 `export const` 替换为接口返回的数据,
+ * 类型定义(interface)可保持不变,作为接口数据结构的约定。
+ */
+
+// ============================== 传承活动 ==============================
+export interface ActivityStat {
+  label: string
+  value: string
+  unit: string
+  /** 左侧强调条颜色(tailwind 类) */
+  accentClass: string
+}
+
+export const activityStats: ActivityStat[] = [
+  { label: '年度课堂', value: '126', unit: '场', accentClass: 'border-l-[#d66b4d]' },
+  { label: '参与人次', value: '2.86', unit: '万', accentClass: 'border-l-[#d9b257]' },
+  { label: '活动类型', value: '8', unit: '类', accentClass: 'border-l-[#c8724e]' },
+]
+
+/** 全年传承计划完成度 */
+export const activityPlan = {
+  progress: 82,
+  desc: '涵盖南音、木偶、剪瓷雕、水仙花雕刻、闽南方言、传统礼俗等体验主题。',
+}
+
+// ============================== 近期活动与体验动态 ==============================
+export interface ActivityItem {
+  image: string
+  headerBgClass: string
+  /** 图片左下角标签 */
+  badge: string
+  /** 日期标签 */
+  date: string
+  /** 日期标签样式(tailwind 类) */
+  dateClass: string
+  icon: string
+  title: string
+  location: string
+  desc: string
+}
+
+export const activityItems: ActivityItem[] = [
+  {
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/cf0865496cf34ff382bd300742f622ac.jpg',
+    headerBgClass: 'bg-[#6d4d2b]',
+    badge: '活动影像',
+    date: '08.16 周日',
+    dateClass: 'bg-[#71362e] text-[#f0b28a]',
+    icon: 'solar:music-note-2-linear',
+    title: '南音雅集·听见古城',
+    location: '漳州古城南音传习所 · 15:00',
+    desc: '传习师现场讲解曲牌与演奏技法',
+  },
+  {
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/246e605a50344cacad22c6658c218d26.jpg',
+    headerBgClass: 'bg-[#65352a]',
+    badge: '工艺细节',
+    date: '08.19 周三',
+    dateClass: 'bg-[#66502c] text-[#f0d37d]',
+    icon: 'solar:palette-round-linear',
+    title: '剪瓷雕纹样工作坊',
+    location: '龙海月港古厝 · 09:30',
+    desc: '展示闽南屋脊纹样的拼贴工艺',
+  },
+  {
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/7fe1c1b3039b48ec9f5c1e2062b87c8f.jpg',
+    headerBgClass: 'bg-[#71362e]',
+    badge: '体验瞬间',
+    date: '08.23 周日',
+    dateClass: 'bg-[#71362e] text-[#f0b28a]',
+    icon: 'solar:mask-happy-linear',
+    title: '布袋木偶亲子体验',
+    location: '芗城非遗展示馆 · 10:00',
+    desc: '亲子共赏木偶操演与经典剧目',
+  },
+]
+
+// ============================== 非遗进校园成果 ==============================
+export interface CampusItem {
+  title: string
+  desc: string
+  accentClass: string
+}
+
+export const campusAchievements: CampusItem[] = [
+  { title: '校本教材 / 通识读本', desc: '编印闽南语童谣、非遗知识读本 24 册', accentClass: 'border-l-[#d66b4d]' },
+  { title: '课程覆盖', desc: '南音、传统工艺等 14 门课程落地', accentClass: 'border-l-[#d9b257]' },
+  { title: '联合授课', desc: '校内教师与传承人联合授课 86 人', accentClass: 'border-l-[#c8724e]' },
+  { title: '校园实践', desc: '学生社团 32 个,年度展演与创作持续入库', accentClass: 'border-l-[#b87749]' },
+]
+
+// ============================== 专业教育 ==============================
+export interface EducationItem {
+  title: string
+  desc: string
+  accentClass: string
+  /** 状态标签,如「已落地」 */
+  status?: string
+}
+
+export const educationItems: EducationItem[] = [
+  {
+    title: '非遗课程体系',
+    desc: '南音、传统工艺等 14 门课程进入校园课堂,形成由基础认知到实践体验的递进式教学。',
+    accentClass: 'border-l-[#d9b257]',
+    status: '已落地',
+  },
+  {
+    title: '校本教材 / 通识读本',
+    desc: '编印闽南语童谣、非遗知识读本 24 册,支持教师备课与学生课后研习。',
+    accentClass: 'border-l-[#d66b4d]',
+  },
+]
+
+// ============================== 师资队伍 ==============================
+export interface Teacher {
+  name: string
+  role: string
+  status: string
+  /** 状态文字颜色(tailwind 类) */
+  statusClass: string
+}
+
+export const teachers: Teacher[] = [
+  { name: '黄文和', role: '南音研究与传习指导', status: '在线', statusClass: 'text-[#e6a276]' },
+  { name: '林淑贞', role: '传统工艺保护咨询', status: '本周驻点', statusClass: 'text-[#d9b257]' },
+]
+
+export const teacherSummary = {
+  label: '传承人与校内教师协作',
+  count: '86 人',
+}
+
+// ============================== 闽南研学路线 ==============================
+export interface StudyRoute {
+  routeNo: string
+  routeNoClass: string
+  title: string
+  desc: string
+  /** 顶部强调条颜色(tailwind 类) */
+  accentClass: string
+}
+
+export const studyRoutes: StudyRoute[] = [
+  {
+    routeNo: '路线 01',
+    routeNoClass: 'text-[#e6a276]',
+    title: '古城听南音',
+    desc: '文庙 · 南音阁 · 古城街巷口述史',
+    accentClass: 'border-t-[#d66b4d]',
+  },
+  {
+    routeNo: '路线 02',
+    routeNoClass: 'text-[#e0b967]',
+    title: '匠心观技艺',
+    desc: '剪瓷雕 · 木偶头雕刻 · 工坊手作',
+    accentClass: 'border-t-[#d9b257]',
+  },
+  {
+    routeNo: '路线 03',
+    routeNoClass: 'text-[#e6a276]',
+    title: '土楼话乡音',
+    desc: '南靖土楼 · 方言采录 · 民俗体验',
+    accentClass: 'border-t-[#c8724e]',
+  },
+]

+ 43 - 0
src/views/home/HomeView.vue

@@ -0,0 +1,43 @@
+<script setup lang="ts">
+import DashboardLayout from '@/components/DashboardLayout.vue'
+
+import StatCard from './components/StatCard.vue'
+import ResourceCategoryPanel from './components/ResourceCategoryPanel.vue'
+import ProtectionStatusPanel from './components/ProtectionStatusPanel.vue'
+import CultureMapPanel from './components/CultureMapPanel.vue'
+import DigitalArchivePanel from './components/DigitalArchivePanel.vue'
+import RecentUpdatesPanel from './components/RecentUpdatesPanel.vue'
+import WorkMechanismPanel from './components/WorkMechanismPanel.vue'
+import InheritorTiersPanel from './components/InheritorTiersPanel.vue'
+import WeeklyPatrolPlanPanel from './components/WeeklyPatrolPlanPanel.vue'
+
+import { kpiStats } from './data'
+</script>
+
+<template>
+  <DashboardLayout>
+    <section class="mt-3 grid grid-cols-4 gap-3">
+      <StatCard v-for="kpi in kpiStats" :key="kpi.id" :kpi="kpi" />
+    </section>
+
+    <section class="mt-3 grid h-[600px] grid-cols-[280px_minmax(0,1fr)_330px] gap-3">
+      <aside class="flex min-h-0 flex-col gap-3">
+        <ResourceCategoryPanel />
+        <ProtectionStatusPanel class="min-h-0 flex-1" />
+      </aside>
+
+      <CultureMapPanel />
+
+      <aside class="flex min-h-0 flex-col gap-3">
+        <DigitalArchivePanel />
+        <RecentUpdatesPanel class="min-h-0 flex-1" />
+      </aside>
+    </section>
+
+    <section class="mt-4 grid grid-cols-[1.05fr_0.95fr_1.1fr] gap-3">
+      <WorkMechanismPanel />
+      <InheritorTiersPanel />
+      <WeeklyPatrolPlanPanel />
+    </section>
+  </DashboardLayout>
+</template>

+ 134 - 0
src/views/home/components/CultureMapPanel.vue

@@ -0,0 +1,134 @@
+<script setup lang="ts">
+import { mapDistricts, mapMarkers, mapFilters, mapUpdateTime } from '../data'
+</script>
+
+<template>
+  <section
+    class="relative min-h-0 overflow-hidden rounded-sm border border-[#7d4936] bg-[#2d1616]/85 p-3"
+  >
+    <div class="flex items-center justify-between">
+      <div>
+        <h2 class="flex items-center gap-1.5 font-bold text-[#f5df9e]">
+          <Icon class="text-[#df925f]" icon="solar:map-point-wave-linear" width="19" />
+          漳州市文化资源分布图
+        </h2>
+        <p class="mt-1 text-[10px] tracking-widest text-[#d59c75]">一城古厝 · 满目花香 · 山海共生</p>
+      </div>
+      <div class="flex gap-1 text-[11px]">
+        <button
+          v-for="f in mapFilters"
+          :key="f.label"
+          class="flex items-center gap-1 rounded-sm border px-3 py-1 transition"
+          :class="
+            f.active
+              ? 'border-[#d86a49] bg-[#8f382d] text-[#ffe0a0]'
+              : 'border-[#704033] text-[#d7b49b] hover:border-[#d86a49]'
+          "
+        >
+          <Icon :icon="f.icon" width="13" />
+          {{ f.label }}
+        </button>
+      </div>
+    </div>
+
+    <div
+      class="absolute inset-x-5 bottom-8 top-20 overflow-hidden rounded border border-[#6e3a31]/70 bg-[radial-gradient(ellipse_at_52%_48%,rgba(149,53,38,.44),transparent_64%)]"
+    >
+      <div
+        class="absolute inset-0 opacity-25"
+        style="
+          background-image: linear-gradient(rgba(216, 106, 73, 0.28) 1px, transparent 1px),
+            linear-gradient(90deg, rgba(216, 106, 73, 0.28) 1px, transparent 1px);
+          background-size: 28px 28px;
+        "
+      ></div>
+
+      <svg
+        aria-label="漳州市行政区域轮廓图"
+        class="absolute inset-[4%_10%] h-[92%] w-[80%] drop-shadow-[0_0_18px_rgba(213,91,65,.42)]"
+        preserveAspectRatio="none"
+        viewBox="0 0 760 420"
+      >
+        <path
+          d="M110 42 L208 19 L289 36 L357 19 L445 48 L558 39 L646 82 L682 128 L662 162 L710 203 L681 239 L698 287 L657 334 L668 378 L599 398 L538 378 L480 404 L402 382 L344 397 L282 370 L213 382 L154 343 L94 317 L69 265 L91 220 L55 181 L82 128 Z"
+          fill="rgba(121,48,39,.54)"
+          stroke="#e07c59"
+          stroke-width="4"
+        ></path>
+        <path
+          d="M110 42 L156 151 L282 135 L289 36 M208 19 L282 135 L402 103 L357 19 M445 48 L402 103 L514 155 L558 39 M646 82 L514 155 L662 162 M156 151 L94 317 M156 151 L282 135 L344 237 L282 370 M402 103 L344 237 L480 404 M514 155 L482 260 L599 398 M662 162 L482 260 L698 287 M344 237 L482 260"
+          fill="none"
+          opacity=".9"
+          stroke="#c6634c"
+          stroke-width="2"
+        ></path>
+        <path
+          d="M55 181 L156 151 L282 135 L402 103 L514 155 L662 162 M94 317 L344 237 L482 260 L698 287"
+          fill="none"
+          opacity=".85"
+          stroke="#d7ae57"
+          stroke-dasharray="6 7"
+          stroke-width="1.4"
+        ></path>
+        <path
+          d="M631 261 Q671 268 698 287 M619 315 Q648 325 657 334 M610 365 Q642 368 668 378"
+          fill="none"
+          opacity=".85"
+          stroke="#e7b968"
+          stroke-width="2"
+        ></path>
+      </svg>
+
+      <div
+        class="absolute right-4 top-3 grid h-12 w-12 place-items-center rounded-full border border-[#bf8749] bg-[#351a18]/85 text-center text-[9px] text-[#f3d88e]"
+      >
+        <span class="absolute top-1">N</span>
+        <Icon class="text-[#dc704e]" icon="solar:arrow-up-linear" width="28" />
+      </div>
+
+      <div
+        v-for="d in mapDistricts"
+        :key="d.name"
+        class="absolute rounded px-2 py-1 text-[10px]"
+        :class="
+          d.highlighted
+            ? 'bg-[#4a1e1a]/90 font-bold text-[#ffe0a0]'
+            : 'bg-[#351817]/85 text-[#f4d695]'
+        "
+        :style="{ left: d.left, top: d.top }"
+      >
+        {{ d.name }}
+      </div>
+
+      <div
+        v-for="m in mapMarkers"
+        :key="m.name"
+        class="absolute flex items-center gap-1"
+        :style="{ left: m.left, top: m.top }"
+      >
+        <i class="shrink-0" :class="m.dotClass"></i>
+        <span class="rounded bg-[#321615]/90 px-1.5 py-0.5 text-[9px]" :class="m.labelClass">
+          {{ m.name }}
+        </span>
+      </div>
+    </div>
+
+    <div
+      class="absolute bottom-3 left-4 flex items-center gap-4 text-[10px] text-[#dfc1a9]"
+    >
+      <span>
+        <i class="mr-1 inline-block h-2.5 w-2.5 rounded-full border border-[#ffe0a0] bg-[#d65c43]"></i>文物
+      </span>
+      <span>
+        <i class="mr-1 inline-block h-2.5 w-2.5 rounded-sm border border-[#ffd1aa] bg-[#bd624b]"></i>非遗项目
+      </span>
+      <span>
+        <i class="mr-1 inline-block h-2.5 w-2.5 rotate-45 border border-[#ffe0a0] bg-[#c9884a]"></i>传统村落
+      </span>
+      <span>
+        <i class="mr-1 inline-block h-2.5 w-2.5 rotate-45 border border-[#ffd1aa] bg-[#e1825c]"></i>传承所
+      </span>
+      <span class="text-[#b98d74]">{{ mapUpdateTime }}</span>
+    </div>
+  </section>
+</template>

+ 70 - 0
src/views/home/components/DigitalArchivePanel.vue

@@ -0,0 +1,70 @@
+<script setup lang="ts">
+import { archivePoint } from '../data'
+</script>
+
+<template>
+  <PanelCard title="数字档案点位">
+    <template #extra>
+      <span class="flex items-center gap-1 text-[10px] text-[#e28a60]">
+        <i class="pulse-dot h-1.5 w-1.5 rounded-full bg-[#cf684c]"></i>监测中
+      </span>
+    </template>
+
+    <div class="mt-3 flex gap-3">
+      <button
+        type="button"
+        class="group relative h-20 w-28 shrink-0 overflow-hidden rounded border border-[#b99857] bg-[#6d4d2b] text-left transition hover:border-[#e1b65b] hover:shadow-[0_0_14px_rgba(225,182,91,.28)]"
+      >
+        <img
+          :alt="archivePoint.name"
+          class="h-full w-full object-cover opacity-70 transition duration-300 group-hover:scale-105 group-hover:opacity-90"
+          :src="archivePoint.image"
+        />
+        <span
+          class="absolute inset-0 grid place-items-center bg-[#351817]/0 text-[10px] text-[#fff0bd] opacity-0 transition group-hover:bg-[#351817]/55 group-hover:opacity-100"
+        >
+          <Icon icon="solar:eye-linear" width="17" />
+        </span>
+        <span class="absolute bottom-1 left-1 rounded bg-[#54351f]/90 px-1.5 py-0.5 text-[9px]">
+          古城夜色
+        </span>
+      </button>
+
+      <div>
+        <h3 class="font-bold text-[#f8e5ac]">{{ archivePoint.name }}</h3>
+        <span class="text-[10px] text-[#e1b861]">{{ archivePoint.tag }}</span>
+        <p class="mt-1 text-[10px] leading-4 text-[#c19b87]">{{ archivePoint.desc }}</p>
+      </div>
+    </div>
+
+    <div class="mt-3 space-y-2 text-[11px] text-[#c6a18c]">
+      <p v-for="row in archivePoint.rows" :key="row.label" class="flex justify-between border-b border-[#6a342d]/60 pb-1">
+        <span>{{ row.label }}</span>
+        <b class="text-[#ebdfb8]">{{ row.value }}</b>
+      </p>
+    </div>
+
+    <div class="mt-3 flex flex-wrap gap-1">
+      <span
+        v-for="tag in archivePoint.tags"
+        :key="tag"
+        class="rounded bg-[#71362e] px-2 py-1 text-[10px] text-[#f0b28a]"
+      >
+        {{ tag }}
+      </span>
+    </div>
+
+    <div class="mt-3">
+      <div class="mb-1 flex justify-between text-[10px]">
+        <span>数字档案完整度</span>
+        <b class="text-[#f0a16d]">{{ archivePoint.completeness }}%</b>
+      </div>
+      <ProgressBar
+        :percent="archivePoint.completeness"
+        height="h-1.5"
+        track-class="bg-[#5b2925]"
+        bar-class="bg-gradient-to-r from-[#c95b43] to-[#d7b254]"
+      />
+    </div>
+  </PanelCard>
+</template>

+ 35 - 0
src/views/home/components/InheritorTiersPanel.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+import { inheritorTiers, inheritorStats } from '../data'
+</script>
+
+<template>
+  <PanelCard title="传承人梯队" subtitle="技艺传习与青年培养" padding="p-4" shadow>
+    <template #extra>
+      <Icon class="text-[#df925f]" icon="solar:users-group-rounded-linear" width="21" />
+    </template>
+
+    <div class="mt-4 space-y-3 text-xs">
+      <div v-for="tier in inheritorTiers" :key="tier.label">
+        <div class="mb-1 flex justify-between">
+          <span>{{ tier.label }}</span>
+          <b class="text-[#f3d181]">{{ tier.count }}</b>
+        </div>
+        <ProgressBar :percent="tier.percent" />
+      </div>
+    </div>
+
+    <div class="mt-5 grid grid-cols-2 gap-2">
+      <div
+        v-for="stat in inheritorStats"
+        :key="stat.label"
+        class="rounded border border-[#744034] bg-[#291515]/70 p-3"
+      >
+        <p class="text-[10px] text-[#ba947e]">{{ stat.label }}</p>
+        <p class="mt-1 text-lg font-bold text-[#fae8b1]">
+          {{ stat.value }} <span class="text-[10px] font-normal">{{ stat.unit }}</span>
+        </p>
+        <p class="text-[10px]" :class="stat.trendColor">{{ stat.trend }}</p>
+      </div>
+    </div>
+  </PanelCard>
+</template>

+ 41 - 0
src/views/home/components/ProtectionStatusPanel.vue

@@ -0,0 +1,41 @@
+<script setup lang="ts">
+import { protectionStatus } from '../data'
+</script>
+
+<template>
+  <PanelCard title="保护态势">
+    <template #extra>
+      <span class="rounded border border-[#a85643] px-2 py-0.5 text-[10px] text-[#e28a60]">实时</span>
+    </template>
+
+    <div class="mt-2 flex items-center gap-3 py-4">
+      <div
+        class="relative grid h-24 w-24 place-items-center rounded-full"
+        :style="{
+          background: `conic-gradient(#c95b43 0 ${protectionStatus.score}%, #6b3029 ${protectionStatus.score}% 100%)`,
+        }"
+      >
+        <div class="grid h-[68px] w-[68px] place-items-center rounded-full bg-[#351817]">
+          <b class="text-2xl text-[#fae8b1]">{{ protectionStatus.score }}</b>
+          <span class="text-[10px] text-[#c19b87]">{{ protectionStatus.scoreLabel }}</span>
+        </div>
+      </div>
+      <div>
+        <p class="font-bold text-[#f1e0ad]">{{ protectionStatus.status }}</p>
+        <p class="mt-2 text-xs text-[#c6a18c]">
+          {{ protectionStatus.change }}
+          <span class="text-[#e28a60]">{{ protectionStatus.changeValue }}</span>
+        </p>
+      </div>
+    </div>
+
+    <div class="space-y-3 border-t border-[#6a342d]/70 pt-3 text-xs">
+      <div v-for="row in protectionStatus.rows" :key="row.label" class="flex justify-between">
+        <span>
+          <i class="mr-2 inline-block h-2 w-2 rounded-full" :class="row.dotColor"></i>{{ row.label }}
+        </span>
+        <b>{{ row.count }}</b>
+      </div>
+    </div>
+  </PanelCard>
+</template>

+ 28 - 0
src/views/home/components/RecentUpdatesPanel.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+import { recentUpdates, archiveNotice } from '../data'
+</script>
+
+<template>
+  <PanelCard title="最近更新动态">
+    <template #extra>
+      <span class="rounded border border-[#a85643] px-2 py-0.5 text-[10px] text-[#e28a60]">更多</span>
+    </template>
+
+    <div class="relative mt-3 space-y-4 border-l border-[#883f34] pl-4 text-[11px] text-[#d4b6a2]">
+      <div v-for="item in recentUpdates" :key="item.text" class="relative">
+        <i
+          class="absolute -left-[22px] top-1 h-2 w-2 rounded-full ring-4 ring-[#351817]"
+          :class="item.dotColor"
+        ></i>
+        <p>{{ item.text }}</p>
+        <span class="text-[10px] text-[#bb8e79]">{{ item.time }}</span>
+      </div>
+    </div>
+
+    <div
+      class="mt-4 rounded border border-[#8f7341]/60 bg-[#6b4f2c]/20 p-2 text-[11px] text-[#dec78d]"
+    >
+      <Icon class="mr-1 align-middle" icon="solar:bell-linear" />{{ archiveNotice }}
+    </div>
+  </PanelCard>
+</template>

+ 30 - 0
src/views/home/components/ResourceCategoryPanel.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { Icon } from '@iconify/vue';
+import { resourceCategories } from '../data'
+import PanelCard from '@/components/PanelCard.vue';
+</script>
+
+<template>
+  <PanelCard title="文化资源分类">
+    <template #extra>
+      <Icon class="text-[#e28a60]" icon="solar:alt-arrow-right-linear" />
+    </template>
+
+    <div class="mt-2 space-y-0.5 text-xs">
+      <div
+        v-for="(item, i) in resourceCategories"
+        :key="item.index"
+        class="flex items-center gap-2 py-2"
+        :class="{ 'border-b border-[#6a342d]/60': i < resourceCategories.length - 1 }"
+      >
+        <span class="text-[#d8a957]">{{ item.index }}</span>
+        <Icon :icon="item.icon" width="19" />
+        <div class="flex-1">
+          <b>{{ item.name }}</b>
+          <p class="mt-0.5 text-[10px] text-[#b99380]">{{ item.desc }}</p>
+        </div>
+        <b class="text-[#f6e4b2]">{{ item.count }}</b>
+      </div>
+    </div>
+  </PanelCard>
+</template>

+ 49 - 0
src/views/home/components/StatCard.vue

@@ -0,0 +1,49 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { KpiStat } from '../data'
+
+const props = defineProps<{ kpi: KpiStat }>()
+
+const option = computed(() => ({
+  animationDuration: 1600,
+  grid: { left: 0, right: 0, top: 1, bottom: 0 },
+  xAxis: {
+    show: false,
+    type: 'category',
+    data: props.kpi.chartData.map((_, j) => j),
+  },
+  yAxis: { show: false, type: 'value' },
+  series: [
+    {
+      type: 'line',
+      data: props.kpi.chartData,
+      smooth: true,
+      symbol: 'none',
+      lineStyle: { color: props.kpi.chartColor, width: 2 },
+      areaStyle: { color: props.kpi.chartColor, opacity: 0.15 },
+    },
+  ],
+}))
+</script>
+
+<template>
+  <article
+    class="relative overflow-hidden rounded-sm 
+    border border-[#7f4034] bg-[#351817]/75 p-3 
+    shadow-[inset_0_0_22px_rgba(194,77,54,.12)] "
+  >
+    <p class="flex items-center gap-1.5 text-xs text-[#d4b6a2]">
+      <Icon :icon="kpi.icon" :class="kpi.iconColor" width="16" />
+      {{ kpi.title }}
+    </p>
+    <div class="mt-1 flex items-end justify-between">
+      <p class="text-2xl font-bold text-[#fae8b1]">
+        {{ kpi.value }}<span class="ml-1 text-xs font-normal">{{ kpi.unit }}</span>
+      </p>
+      <span class="text-xs" :class="kpi.trendColor">{{ kpi.trend }}</span>
+    </div>
+    <div class="mt-2 h-25">
+      <VChart :option="option" autoresize />
+    </div>
+  </article>
+</template>

+ 33 - 0
src/views/home/components/WeeklyPatrolPlanPanel.vue

@@ -0,0 +1,33 @@
+<script setup lang="ts">
+import { patrolItems } from '../data'
+</script>
+
+<template>
+  <PanelCard title="本周巡查计划" subtitle="重点资源动态巡护" padding="p-4" shadow>
+    <template #extra>
+      <span class="flex items-center gap-1 text-[10px] text-[#e6a36f]">
+        <i class="pulse-dot h-1.5 w-1.5 rounded-full bg-[#cf684c]"></i>12 项进行中
+      </span>
+    </template>
+
+    <div class="mt-3 space-y-2 text-xs">
+      <div
+        v-for="item in patrolItems"
+        :key="item.title"
+        class="flex items-center gap-3 rounded border border-[#723b32] bg-[#2a1515]/70 p-2.5"
+      >
+        <span
+          class="grid h-8 w-8 place-items-center rounded border"
+          :class="item.iconBoxClass"
+        >
+          <Icon :icon="item.icon" width="18" />
+        </span>
+        <div class="flex-1">
+          <b>{{ item.title }}</b>
+          <p class="mt-1 text-[10px] text-[#bd9681]">{{ item.detail }}</p>
+        </div>
+        <span class="text-[10px]" :class="item.dateColor">{{ item.dateLabel }}</span>
+      </div>
+    </div>
+  </PanelCard>
+</template>

+ 30 - 0
src/views/home/components/WorkMechanismPanel.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { mechanismItems } from '../data'
+</script>
+
+<template>
+  <PanelCard title="工作机制" subtitle="建设管理 · 统筹保障" padding="p-4" shadow>
+    <template #extra>
+      <Icon class="text-[#df925f]" icon="solar:clipboard-list-linear" width="21" />
+    </template>
+
+    <div class="mt-3 space-y-2.5">
+      <div
+        v-for="item in mechanismItems"
+        :key="item.title"
+        class="flex items-start gap-3 rounded border border-[#713b31] bg-[#2b1515]/70 p-2.5"
+      >
+        <span
+          class="grid h-8 w-8 shrink-0 place-items-center rounded border"
+          :class="item.iconBoxClass"
+        >
+          <Icon :icon="item.icon" width="18" />
+        </span>
+        <div>
+          <h3 class="text-xs font-bold text-[#fae8b1]">{{ item.title }}</h3>
+          <p class="mt-1 text-[10px] leading-4 text-[#bf9986]">{{ item.desc }}</p>
+        </div>
+      </div>
+    </div>
+  </PanelCard>
+</template>

+ 381 - 0
src/views/home/data.ts

@@ -0,0 +1,381 @@
+/**
+ * 首页静态数据
+ *
+ * 说明:以下均为按设计图编写的静态示例数据,用于前端页面搭建。
+ * 后续对接后端时,请将各 `export const` 替换为接口返回的数据,
+ * 类型定义(interface)可保持不变,作为接口数据结构的约定。
+ */
+
+// ============================== 顶部 KPI 指标卡 ==============================
+export interface KpiStat {
+  id: string
+  /** 指标名称 */
+  title: string
+  /** iconify solar 图标名 */
+  icon: string
+  /** 图标颜色(tailwind 类) */
+  iconColor: string
+  /** 显示值(已格式化,含千分位等) */
+  value: string
+  /** 单位 */
+  unit: string
+  /** 环比趋势文案 */
+  trend: string
+  /** 趋势文案颜色(tailwind 类) */
+  trendColor: string
+  /** 迷你趋势线数据 */
+  chartData: number[]
+  /** 迷你趋势线颜色 */
+  chartColor: string
+}
+
+export const kpiStats: KpiStat[] = [
+  {
+    id: 'ich',
+    title: '非遗代表性项目',
+    icon: 'solar:medal-ribbons-star-linear',
+    iconColor: 'text-[#e28a60]',
+    value: '368',
+    unit: '项',
+    trend: '较上月 ↑ 12 项',
+    trendColor: 'text-[#dd9871]',
+    chartData: [3, 4, 6, 5, 7, 8, 11, 10, 15, 17],
+    chartColor: '#bf604c',
+  },
+  {
+    id: 'inheritor',
+    title: '非遗代表性传承人',
+    icon: 'solar:user-id-linear',
+    iconColor: 'text-[#e28a60]',
+    value: '286',
+    unit: '位',
+    trend: '较上月 ↑ 8 位',
+    trendColor: 'text-[#e28a60]',
+    chartData: [2, 4, 5, 4, 7, 6, 10, 8, 12, 14],
+    chartColor: '#d97652',
+  },
+  {
+    id: 'relic',
+    title: '不可移动文物',
+    icon: 'solar:buildings-2-linear',
+    iconColor: 'text-[#e3bd65]',
+    value: '1,248',
+    unit: '处',
+    trend: '较上月 ↑ 35 处',
+    trendColor: 'text-[#e3bd65]',
+    chartData: [3, 5, 7, 6, 9, 10, 14, 12, 16, 18],
+    chartColor: '#d7b254',
+  },
+  {
+    id: 'digital',
+    title: '文化数字资源',
+    icon: 'solar:database-linear',
+    iconColor: 'text-[#d79b67]',
+    value: '32.45',
+    unit: '万条',
+    trend: '较上月 ↑ 2.31 万条',
+    trendColor: 'text-[#d79b67]',
+    chartData: [2, 3, 4, 5, 3, 7, 9, 8, 11, 13],
+    chartColor: '#c8865b',
+  },
+]
+
+// ============================== 文化资源分类 ==============================
+export interface ResourceCategory {
+  /** 序号展示 */
+  index: string
+  icon: string
+  name: string
+  /** 分类说明 */
+  desc: string
+  /** 数量(显示值) */
+  count: string
+}
+
+export const resourceCategories: ResourceCategory[] = [
+  {
+    index: '01',
+    icon: 'solar:medal-ribbons-star-linear',
+    name: '非遗项目',
+    desc: '传统技艺、仪式节庆',
+    count: '368',
+  },
+  {
+    index: '02',
+    icon: 'solar:user-id-linear',
+    name: '非遗传承人',
+    desc: '国家级、省级、市级',
+    count: '286',
+  },
+  {
+    index: '03',
+    icon: 'solar:buildings-2-linear',
+    name: '不可移动文物',
+    desc: '古厝、古建筑、古遗址',
+    count: '1,248',
+  },
+  {
+    index: '04',
+    icon: 'solar:home-2-linear',
+    name: '传统村落',
+    desc: '历史文化名村',
+    count: '156',
+  },
+  {
+    index: '05',
+    icon: 'solar:music-note-2-linear',
+    name: '闽南方言',
+    desc: '传习、展示、交流',
+    count: '98',
+  },
+]
+
+// ============================== 保护态势 ==============================
+export interface ProtectionStatusRow {
+  label: string
+  count: string
+  /** 指示点颜色(tailwind 类) */
+  dotColor: string
+}
+
+export const protectionStatus = {
+  /** 综合指数(环形进度 0-100) */
+  score: 86,
+  scoreLabel: '综合指数',
+  /** 态势结论 */
+  status: '整体平稳',
+  /** 环比变化文案 */
+  change: '较上月提升',
+  changeValue: '3.2%',
+  rows: [
+    { label: '正常开放', count: '46 处', dotColor: 'bg-[#cf684c]' },
+    { label: '修缮保护', count: '32 处', dotColor: 'bg-[#e1b85d]' },
+    { label: '待提升', count: '18 处', dotColor: 'bg-[#d56b53]' },
+  ] as ProtectionStatusRow[],
+}
+
+// ============================== 漳州市文化资源分布图 ==============================
+export interface MapDistrict {
+  name: string
+  /** 位置(相对容器百分比) */
+  left: string
+  top: string
+  /** 是否重点高亮(中心城区) */
+  highlighted?: boolean
+}
+
+export const mapDistricts: MapDistrict[] = [
+  { name: '华安', left: '26%', top: '15%' },
+  { name: '长泰', left: '42%', top: '10%' },
+  { name: '芗城·龙文', left: '52%', top: '27%', highlighted: true },
+  { name: '龙海', left: '66%', top: '34%' },
+  { name: '南靖', left: '34%', top: '48%' },
+  { name: '平和', left: '51%', top: '53%' },
+  { name: '漳浦', left: '70%', top: '57%' },
+  { name: '云霄', left: '28%', top: '73%' },
+  { name: '诏安', left: '46%', top: '78%' },
+  { name: '东山', left: '66%', top: '76%' },
+]
+
+export interface MapMarker {
+  name: string
+  left: string
+  top: string
+  /** 标记点样式(tailwind 类) */
+  dotClass: string
+  /** 名称标签颜色(tailwind 类) */
+  labelClass: string
+}
+
+export const mapMarkers: MapMarker[] = [
+  {
+    name: '漳州古城',
+    left: '48%',
+    top: '31%',
+    dotClass: 'pulse-dot h-4 w-4 rounded-full border-2 border-[#ffe0a0] bg-[#d65c43] shadow-[0_0_12px_#d4513c]',
+    labelClass: 'text-[#ffe0a0]',
+  },
+  {
+    name: '南靖土楼',
+    left: '37%',
+    top: '50%',
+    dotClass: 'h-3.5 w-3.5 rotate-45 border border-[#ffe0a0] bg-[#c9884a] shadow-[0_0_8px_#c9884a]',
+    labelClass: 'text-[#f5d987]',
+  },
+  {
+    name: '剪瓷雕',
+    left: '59%',
+    top: '48%',
+    dotClass: 'h-3.5 w-3.5 rounded-sm border border-[#ffd1aa] bg-[#bd624b]',
+    labelClass: 'text-[#f1b78e]',
+  },
+  {
+    name: '赵家堡',
+    left: '70%',
+    top: '61%',
+    dotClass: 'h-3.5 w-3.5 rounded-full border border-[#ffe6ae] bg-[#d6a74e]',
+    labelClass: 'text-[#f4d695]',
+  },
+  {
+    name: '闽南传承所',
+    left: '45%',
+    top: '70%',
+    dotClass: 'h-3.5 w-3.5 rotate-45 border border-[#ffd1aa] bg-[#e1825d]',
+    labelClass: 'text-[#f2bd98]',
+  },
+  {
+    name: '东山关帝庙',
+    left: '63%',
+    top: '77%',
+    dotClass: 'h-3.5 w-3.5 rounded-sm border border-[#ffd1aa] bg-[#bd624b]',
+    labelClass: 'text-[#f1b78e]',
+  },
+]
+
+export interface MapFilter {
+  label: string
+  icon: string
+  /** 是否默认选中 */
+  active?: boolean
+}
+
+export const mapFilters: MapFilter[] = [
+  { label: '全部', icon: 'solar:map-linear', active: true },
+  { label: '文物', icon: 'solar:buildings-2-linear' },
+  { label: '非遗', icon: 'solar:medal-ribbons-star-linear' },
+  { label: '村落', icon: 'solar:house-linear' },
+  { label: '传承所', icon: 'solar:chat-round-dots-linear' },
+]
+
+export const mapUpdateTime = '数据更新:2026-08-11 17:20'
+
+// ============================== 数字档案点位 ==============================
+export const archivePoint = {
+  name: '漳州古城',
+  tag: '核心区',
+  image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/6c0f118cb21a4564ae26d81eb2a2dcf0.jpg',
+  desc: '骑楼与红砖古厝交织,保留闽南生活肌理。',
+  rows: [
+    { label: '所在区域', value: '芗城区' },
+    { label: '资源类型', value: '历史文化街区' },
+    { label: '保护级别', value: '国家级历史文化街区' },
+  ],
+  tags: ['古城街区', '传统聚落', '历史建筑'],
+  /** 数字档案完整度(百分比) */
+  completeness: 92,
+}
+
+// ============================== 最近更新动态 ==============================
+export interface UpdateItem {
+  text: string
+  time: string
+  /** 时间线节点颜色(tailwind 类) */
+  dotColor: string
+}
+
+export const recentUpdates: UpdateItem[] = [
+  {
+    text: '新增《水仙花雕刻技艺》影像资料',
+    time: '17:20 · 芗城档案馆',
+    dotColor: 'bg-[#cf684c]',
+  },
+  {
+    text: '完成漳州古城骑楼三维建模复核',
+    time: '14:46 · 古城保护中心',
+    dotColor: 'bg-[#d9ae59]',
+  },
+  {
+    text: '新建南靖土楼口述史音频 26 条',
+    time: '09:30 · 非遗中心',
+    dotColor: 'bg-[#cf684c]',
+  },
+]
+
+export const archiveNotice = '本月完成 18 处传统建筑巡查'
+
+// ============================== 工作机制 ==============================
+export interface MechanismItem {
+  icon: string
+  iconBoxClass: string
+  title: string
+  desc: string
+}
+
+export const mechanismItems: MechanismItem[] = [
+  {
+    icon: 'solar:buildings-2-linear',
+    iconBoxClass: 'border-[#b97e48] bg-[#6a4329] text-[#f2d488]',
+    title: '机构建设',
+    desc: '建立独立健全的国家级文化生态保护区建设管理机构。',
+  },
+  {
+    icon: 'solar:calendar-mark-linear',
+    iconBoxClass: 'border-[#b9644a] bg-[#703129] text-[#f0b08a]',
+    title: '统筹管理',
+    desc: '将保护区建设纳入当地政府年度工作任务和考核评价体系。',
+  },
+  {
+    icon: 'solar:users-group-rounded-linear',
+    iconBoxClass: 'border-[#b97e48] bg-[#6a4329] text-[#f2d488]',
+    title: '专职人员',
+    desc: '配备一定数量的专职工作人员,满足建设工作需求。',
+  },
+]
+
+// ============================== 传承人梯队 ==============================
+export interface InheritorTier {
+  label: string
+  count: string
+  /** 占比(百分比,用于进度条宽度) */
+  percent: number
+}
+
+export const inheritorTiers: InheritorTier[] = [
+  { label: '国家级代表性传承人', count: '18 位', percent: 82 },
+  { label: '省级代表性传承人', count: '76 位', percent: 68 },
+  { label: '市县级传承骨干', count: '192 位', percent: 91 },
+]
+
+export const inheritorStats = [
+  { label: '青年学员入库', value: '1,684', unit: '人', trend: '环比增长 11.4%', trendColor: 'text-[#e17a55]' },
+  { label: '传习所常态课程', value: '42', unit: '门', trend: '本周新增 3 门', trendColor: 'text-[#d9b25b]' },
+]
+
+// ============================== 本周巡查计划 ==============================
+export interface PatrolItem {
+  icon: string
+  iconBoxClass: string
+  title: string
+  detail: string
+  /** 日期标签文案 */
+  dateLabel: string
+  /** 日期标签颜色(tailwind 类) */
+  dateColor: string
+}
+
+export const patrolItems: PatrolItem[] = [
+  {
+    icon: 'solar:buildings-2-linear',
+    iconBoxClass: 'border-[#b97e48] bg-[#6a4329] text-[#f2d488]',
+    title: '南靖土楼群汛期安全巡检',
+    detail: '南靖县书洋镇 · 08:30—11:30',
+    dateLabel: '今日',
+    dateColor: 'text-[#e5b762]',
+  },
+  {
+    icon: 'solar:microphone-3-linear',
+    iconBoxClass: 'border-[#b9644a] bg-[#703129] text-[#f0b08a]',
+    title: '闽南传承所设备校准',
+    detail: '诏安县梅岭镇 · 14:00—16:00',
+    dateLabel: '明日',
+    dateColor: 'text-[#e58b62]',
+  },
+  {
+    icon: 'solar:camera-linear',
+    iconBoxClass: 'border-[#b97e48] bg-[#6a4329] text-[#f2d488]',
+    title: '古城历史建筑影像补采',
+    detail: '芗城区古城片区 · 09:00—17:00',
+    dateLabel: '周四',
+    dateColor: 'text-[#d8b15d]',
+  },
+]

+ 20 - 0
src/views/protection-overview/ProtectionOverviewView.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+import DashboardLayout from '@/components/DashboardLayout.vue'
+
+import OverviewCard from './components/OverviewCard.vue'
+import { overviewCards } from './data'
+</script>
+
+<template>
+  <DashboardLayout subtitle="" footer-middle="保护概况 · 数字驾驶舱" footer-right="更新于 2026-08-19">
+    <section class="mt-5 flex items-end justify-between border-b border-[#7f4034]/70 pb-4">
+      <div>
+        <h1 class="mt-2 text-[30px] font-bold tracking-[.16em] text-[#fff0bd]">保护区概况</h1>
+      </div>
+    </section>
+
+    <section class="mt-5 grid grid-cols-2 gap-4">
+      <OverviewCard v-for="card in overviewCards" :key="card.id" :card="card" />
+    </section>
+  </DashboardLayout>
+</template>

+ 51 - 0
src/views/protection-overview/components/OverviewCard.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import type { OverviewCard } from '../data'
+
+defineProps<{ card: OverviewCard }>()
+</script>
+
+<template>
+  <article
+    class="group overflow-hidden border border-[#7f4034] bg-[#351817]/85 shadow-[inset_0_0_28px_rgba(194,77,54,.12)] transition hover:border-[#c98755]"
+  >
+    <div class="relative h-44 overflow-hidden border-b border-[#7f4034]" :class="card.headerBgClass">
+      <img
+        :alt="card.imageAlt"
+        class="h-full w-full object-cover opacity-80"
+        :src="card.image"
+      />
+      <div
+        class="absolute left-4 top-4 border border-[#f0c77b]/70 bg-[#3b1b18]/80 px-3 py-1 text-xs text-[#ffe7aa]"
+      >
+        {{ card.badge }}
+      </div>
+      <span class="absolute bottom-3 right-4 text-xs tracking-[.2em] text-[#ffe1a0]">
+        {{ card.corner }}
+      </span>
+    </div>
+
+    <div class="p-5">
+      <div class="flex items-start gap-3">
+        <span class="mt-1 h-8 w-1 shrink-0" :class="card.accentClass"></span>
+        <div>
+          <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">{{ card.title }}</h2>
+        </div>
+      </div>
+      <p class="mt-4 text-sm leading-7 text-[#c8a289]">{{ card.desc }}</p>
+      <div class="mt-4 flex gap-2 text-xs">
+        <span
+          v-for="(tag, i) in card.tags"
+          :key="tag"
+          class="px-3 py-1.5"
+          :class="
+            i === 0
+              ? 'border border-[#92503e] bg-[#4a211d] text-[#edbd78]'
+              : 'border border-[#79503c] text-[#d8b49b]'
+          "
+        >
+          {{ tag }}
+        </span>
+      </div>
+    </div>
+  </article>
+</template>

+ 78 - 0
src/views/protection-overview/data.ts

@@ -0,0 +1,78 @@
+/**
+ * 保护区概况页静态数据
+ *
+ * 说明:以下均为按设计图编写的静态示例数据,用于前端页面搭建。
+ * 后续对接后端时,请将各 `export const` 替换为接口返回的数据,
+ * 类型定义(interface)可保持不变,作为接口数据结构的约定。
+ */
+
+export interface OverviewCard {
+  id: string
+  /** 配图地址 */
+  image: string
+  /** 配图替代文本 */
+  imageAlt: string
+  /** 配图容器背景色(图片加载前的占位色) */
+  headerBgClass: string
+  /** 配图左上角徽标 */
+  badge: string
+  /** 配图右下角标签 */
+  corner: string
+  /** 标题左侧强调条颜色(tailwind 类) */
+  accentClass: string
+  title: string
+  desc: string
+  /** 标签,首个为高亮(金色)样式 */
+  tags: string[]
+}
+
+export const overviewCards: OverviewCard[] = [
+  {
+    id: 'zhangzhou-reserve',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-19/79eb1694bf424b6e83c9f9a0169944d5.jpg',
+    imageAlt: 'Zhangzhou cultural ecology reserve traditional architecture',
+    headerBgClass: 'bg-[#51271f]',
+    badge: '漳州保护区',
+    corner: '整体保护',
+    accentClass: 'bg-[#c6533d]',
+    title: '闽南文化(漳州)生态保护区基本情况',
+    desc: '立足漳州深厚文脉,统筹传统建筑、非遗项目、民俗活动与文化空间的保护传承,推动文化遗产融入城乡生活。',
+    tags: ['资源整体保护', '活态传承发展'],
+  },
+  {
+    id: 'protection-center',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-19/4d747a42c8fd4dbea8d99d2687970849.jpg',
+    imageAlt: 'Minnan culture protection center public service',
+    headerBgClass: 'bg-[#45231e]',
+    badge: '保护中心',
+    corner: '服务传承',
+    accentClass: 'bg-[#d9a553]',
+    title: '漳州市闽南文化保护中心',
+    desc: '围绕闽南文化资源保护、传承传播与公共服务,协同推进资料整理、展示交流和社会参与,为文化生态保护提供持续支撑。',
+    tags: ['保护服务支撑', '文化传播交流'],
+  },
+  {
+    id: 'minnan-ecology',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-19/3cefb0d3c04d479f968fd7e70d99b68b.jpg',
+    imageAlt: 'Minnan cultural ecology traditional performance',
+    headerBgClass: 'bg-[#3d1b1b]',
+    badge: '闽南文化',
+    corner: '活态传承',
+    accentClass: 'bg-[#c6533d]',
+    title: '闽南文化生态保护区基本情况',
+    desc: '关注闽南方言、音乐戏曲、传统技艺、民间信俗与聚落风貌之间的关联,以区域协同和公众参与维护文化生态的完整性。',
+    tags: ['区域协同守护', '文化生态延续'],
+  },
+  {
+    id: 'national-overview',
+    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-19/7c8f3a21c1a8423cae818dba0fd67fae.jpg',
+    imageAlt: 'National cultural ecology protection landscape',
+    headerBgClass: 'bg-[#4b271c]',
+    badge: '全国视野',
+    corner: '全国联动',
+    accentClass: 'bg-[#d9a553]',
+    title: '全国文化生态保护区基本情况',
+    desc: '以鲜明特色、丰富内涵和重要价值的文化形态为核心,探索整体性保护、系统性传承与可持续发展的实践路径。',
+    tags: ['整体性保护', '可持续发展'],
+  },
+]

+ 28 - 0
src/views/public-communication/PublicCommunicationView.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+import DashboardLayout from '@/components/DashboardLayout.vue'
+
+import CommunicationPanel from './components/CommunicationPanel.vue'
+import ConstructionPanel from './components/ConstructionPanel.vue'
+import VolunteerPanel from './components/VolunteerPanel.vue'
+import FeedbackPanel from './components/FeedbackPanel.vue'
+</script>
+
+<template>
+  <DashboardLayout footer-middle="数据来源:公众传播与文化记忆共建平台 · 实时汇聚">
+    <section class="mt-5 flex items-end justify-between">
+      <div>
+        <h1 class="mt-2 text-[30px] font-bold tracking-[.16em] text-[#fff0bd]">公众传播</h1>
+      </div>
+    </section>
+
+    <section class="mt-5 grid grid-cols-12 gap-4">
+      <CommunicationPanel />
+      <ConstructionPanel />
+    </section>
+
+    <section class="mt-4 grid grid-cols-12 gap-4">
+      <VolunteerPanel />
+      <FeedbackPanel />
+    </section>
+  </DashboardLayout>
+</template>

+ 44 - 0
src/views/public-communication/components/CommunicationPanel.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { communicationTopics } from '../data'
+
+// 点击话题后的反馈
+const feedback = ref('')
+
+function onTopicClick(name: string) {
+  feedback.value = `已打开「${name}」分类入口,可继续浏览相关传播内容。`
+}
+</script>
+
+<template>
+  <article
+    class="col-span-12 border border-[#8b493b] bg-[#351817]/85 p-5 shadow-[inset_0_0_28px_rgba(194,77,54,.14)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">宣传传播</h2>
+      </div>
+    </div>
+
+    <div class="mt-4 grid grid-cols-4 gap-3 text-xs">
+      <button
+        v-for="topic in communicationTopics"
+        :key="topic.name"
+        type="button"
+        class="border border-[#8d5542] bg-[#45211d] px-3 py-4 text-left transition hover:border-[#e0b86d] hover:bg-[#5a2a22]"
+        @click="onTopicClick(topic.name)"
+      >
+        <Icon class="text-[#e0b86d]" :icon="topic.icon" width="22" />
+        <b class="mt-2 block text-[#f3dca1]">{{ topic.name }}</b>
+        <span class="mt-1 block leading-5 text-[#b99380]">{{ topic.desc }}</span>
+      </button>
+    </div>
+
+    <p
+      v-if="feedback"
+      class="mt-3 border-l-2 border-[#d0734e] bg-[#4a211d] px-3 py-2 text-xs text-[#f0d7a2]"
+    >
+      {{ feedback }}
+    </p>
+  </article>
+</template>

+ 27 - 0
src/views/public-communication/components/ConstructionPanel.vue

@@ -0,0 +1,27 @@
+<script setup lang="ts">
+import { constructionUpdates } from '../data'
+</script>
+
+<template>
+  <article
+    class="col-span-12 border border-[#7f4034] bg-[#351817]/80 p-4 shadow-[inset_0_0_22px_rgba(194,77,54,.12)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">建设动态</h2>
+      </div>
+    </div>
+
+    <div class="mt-3 space-y-2 text-xs">
+      <div
+        v-for="update in constructionUpdates"
+        :key="update.title"
+        class="border-l-2 bg-[#2b1514]/65 py-2 pl-3"
+        :class="update.accentClass"
+      >
+        <b class="text-[#f3dca1]">{{ update.title }}</b>
+        <p class="mt-1 leading-5 text-[#b99380]">{{ update.desc }}</p>
+      </div>
+    </div>
+  </article>
+</template>

+ 35 - 0
src/views/public-communication/components/FeedbackPanel.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+import { feedbackItems } from '../data'
+</script>
+
+<template>
+  <article
+    class="col-span-3 border border-[#7f4034] bg-[#351817]/75 p-4 shadow-[inset_0_0_18px_rgba(194,77,54,.08)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <div>
+        <h2 class="mt-1 text-lg font-bold text-[#f5df9e]">公众留言反馈</h2>
+      </div>
+      <span
+        class="grid h-9 w-9 place-items-center rounded-sm border border-[#af7445] bg-[#5d3028] text-[#f1cf85]"
+      >
+        <Icon icon="solar:chat-round-dots-linear" width="21" />
+      </span>
+    </div>
+
+    <div class="mt-3 grid grid-cols-1 gap-3 text-xs">
+      <div
+        v-for="item in feedbackItems"
+        :key="item.title"
+        class="border-l-2 bg-[#2b1514]/65 py-2 pl-3"
+        :class="item.accentClass"
+      >
+        <div class="flex justify-between">
+          <b>{{ item.title }}</b>
+          <span :class="item.statusClass">{{ item.status }}</span>
+        </div>
+        <p class="mt-1 leading-5 text-[#b99380]">{{ item.desc }}</p>
+      </div>
+    </div>
+  </article>
+</template>

+ 35 - 0
src/views/public-communication/components/VolunteerPanel.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { volunteerInfo } from '../data'
+
+// 点击按钮后的反馈
+const feedback = ref('')
+
+function onVolunteerClick() {
+  feedback.value = '志愿者报名与组织服务入口已准备就绪。'
+}
+</script>
+
+<template>
+  <article
+    class="col-span-9 border border-[#7f4034] bg-[#351817]/80 p-4 shadow-[inset_0_0_18px_rgba(194,77,54,.1)]"
+  >
+    <div class="flex items-center justify-between border-b border-[#7f4034]/70 pb-3">
+      <h2 class="text-lg font-bold text-[#f5df9e]">志愿者组织</h2>
+    </div>
+
+    <p class="mt-4 text-xs leading-5 text-[#b99380]">{{ volunteerInfo.desc }}</p>
+    <div class="mt-3 flex items-end gap-2">
+      <strong class="text-3xl text-[#e0b967]">{{ volunteerInfo.count }}</strong>
+      <span class="pb-1 text-xs text-[#c8a289]">名活跃志愿者</span>
+    </div>
+    <button
+      type="button"
+      class="mt-3 w-full border border-[#8d5542] bg-[#45211d] px-3 py-2 text-xs text-[#f3dca1] transition hover:border-[#e0b86d]"
+      @click="onVolunteerClick"
+    >
+      查看组织信息
+    </button>
+    <p v-if="feedback" class="mt-2 text-xs text-[#f0d7a2]">{{ feedback }}</p>
+  </article>
+</template>

+ 84 - 0
src/views/public-communication/data.ts

@@ -0,0 +1,84 @@
+/**
+ * 公众传播页静态数据
+ *
+ * 说明:以下均为按设计图编写的静态示例数据,用于前端页面搭建。
+ * 后续对接后端时,请将各 `export const` 替换为接口返回的数据,
+ * 类型定义(interface)可保持不变,作为接口数据结构的约定。
+ */
+
+// ============================== 宣传传播 ==============================
+export interface CommunicationTopic {
+  name: string
+  icon: string
+  desc: string
+}
+
+export const communicationTopics: CommunicationTopic[] = [
+  { name: '专题展示', icon: 'solar:monitor-linear', desc: '保护区主题图文与影像' },
+  { name: '品牌活动', icon: 'solar:star-rainbow-linear', desc: '节庆活动与文化品牌' },
+  { name: '闽南文化交流', icon: 'solar:users-group-rounded-linear', desc: '区域交流与共享传播' },
+  { name: '宣传报道', icon: 'solar:document-text-linear', desc: '新闻资讯与保护动态' },
+  { name: '宣传物料', icon: 'solar:archive-linear', desc: '海报、手册与数字素材' },
+  { name: '视觉形象', icon: 'solar:palette-linear', desc: '保护区统一视觉识别' },
+  { name: '保护区标识牌', icon: 'solar:signpost-2-linear', desc: '点位导视与文化说明' },
+]
+
+// ============================== 建设动态 ==============================
+export interface ConstructionUpdate {
+  title: string
+  desc: string
+  /** 左侧强调条颜色(tailwind 类) */
+  accentClass: string
+}
+
+export const constructionUpdates: ConstructionUpdate[] = [
+  {
+    title: '生态修复',
+    desc: '推进重点村落水系与古树群落修复,完善生态监测。',
+    accentClass: 'border-l-[#86a565]',
+  },
+  {
+    title: '生活环境优化',
+    desc: '持续改善传统村落公共空间与游览环境,提升居民体验。',
+    accentClass: 'border-l-[#d9b257]',
+  },
+  {
+    title: '设施更新',
+    desc: '补充文化展示、导览与无障碍服务设施。',
+    accentClass: 'border-l-[#d66b4d]',
+  },
+]
+
+// ============================== 志愿者组织 ==============================
+export const volunteerInfo = {
+  count: '128',
+  desc: '汇聚文化讲解、环境维护与活动协助力量。',
+}
+
+// ============================== 公众留言反馈 ==============================
+export interface FeedbackItem {
+  title: string
+  status: string
+  /** 状态文字颜色(tailwind 类) */
+  statusClass: string
+  desc: string
+  /** 左侧强调条颜色(tailwind 类) */
+  accentClass: string
+}
+
+export const feedbackItems: FeedbackItem[] = [
+  {
+    title: '古城骑楼修缮进度咨询',
+    status: '已处理',
+    statusClass: 'text-[#e6a276]',
+    desc: '已更新第三季度外立面修缮节点与街巷通行提示。',
+    accentClass: 'border-l-[#d66b4d]',
+  },
+  {
+    title: '希望增加闽南语童谣素材',
+    status: '已采纳',
+    statusClass: 'text-[#e0b967]',
+    desc: '童谣有声专辑已纳入本月科普内容更新计划。',
+    accentClass: 'border-l-[#d9b257]',
+  },
+]

+ 18 - 0
tsconfig.app.json

@@ -0,0 +1,18 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
+  "exclude": ["src/**/__tests__/*"],
+  "compilerOptions": {
+    // Extra safety for array and object lookups, but may have false positives.
+    "noUncheckedIndexedAccess": true,
+
+    // Path mapping for cleaner imports.
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+
+    // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
+    // Specified here to keep it out of the root directory.
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
+  }
+}

+ 11 - 0
tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.node.json"
+    },
+    {
+      "path": "./tsconfig.app.json"
+    }
+  ]
+}

+ 27 - 0
tsconfig.node.json

@@ -0,0 +1,27 @@
+// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
+{
+  "extends": "@tsconfig/node24/tsconfig.json",
+  "include": [
+    "vite.config.*",
+    "vitest.config.*",
+    "cypress.config.*",
+    "playwright.config.*",
+    "eslint.config.*"
+  ],
+  "compilerOptions": {
+    // Most tools use transpilation instead of Node.js's native type-stripping.
+    // Bundler mode provides a smoother developer experience.
+    "module": "preserve",
+    "moduleResolution": "bundler",
+
+    // Include Node.js types and avoid accidentally including other `@types/*` packages.
+    "types": ["node"],
+
+    // Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
+    "noEmit": true,
+
+    // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
+    // Specified here to keep it out of the root directory.
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
+  }
+}

+ 21 - 0
vite.config.ts

@@ -0,0 +1,21 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueDevTools from 'vite-plugin-vue-devtools'
+import tailwindcss from '@tailwindcss/vite'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [
+    vue(),
+    vueDevTools(),
+    tailwindcss(),
+  ],
+  base: './',
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url)),
+    },
+  },
+})